diff --git a/.chronus/changes/copilot-fix-head-request-content-type-2026-03-02-19-45-00.md b/.chronus/changes/copilot-fix-head-request-content-type-2026-03-02-19-45-00.md new file mode 100644 index 00000000000..20f85944838 --- /dev/null +++ b/.chronus/changes/copilot-fix-head-request-content-type-2026-03-02-19-45-00.md @@ -0,0 +1,8 @@ +--- +changeKind: fix +packages: + - "@typespec/http" +--- + +- Emit a `head-no-body` warning when a `@head` operation response contains a body (HTTP spec: "head request must not return a message-body in the response"). +- Fix the `content-type-ignored` warning incorrectly firing for `@head` responses that have a content-type header but no body. diff --git a/packages/http/src/lib.ts b/packages/http/src/lib.ts index b67083b9e39..135fbce406d 100644 --- a/packages/http/src/lib.ts +++ b/packages/http/src/lib.ts @@ -96,6 +96,12 @@ export const $lib = createTypeSpecLibrary({ default: "`Content-Type` header ignored because there is no body.", }, }, + "head-operation-no-body": { + severity: "warning", + messages: { + default: "head request must not return a message-body in the response", + }, + }, "metadata-ignored": { severity: "warning", messages: { diff --git a/packages/http/src/responses.ts b/packages/http/src/responses.ts index 281030fd339..da76d8b84e8 100644 --- a/packages/http/src/responses.ts +++ b/packages/http/src/responses.ts @@ -21,7 +21,7 @@ import { getStatusCodesWithDiagnostics, } from "./decorators.js"; import { HttpProperty } from "./http-property.js"; -import { HttpStateKeys, reportDiagnostic } from "./lib.js"; +import { createDiagnostic, HttpStateKeys, reportDiagnostic } from "./lib.js"; import { Visibility } from "./metadata.js"; import { HttpPayloadDisposition, resolveHttpPayload } from "./payload.js"; import { HttpOperationResponse, HttpStatusCodes, HttpStatusCodesEntry } from "./types.js"; @@ -133,9 +133,6 @@ function processResponseType( getResponseStatusCodes(program, responseType, metadata), ); - // Get response headers - const headers = getResponseHeaders(program, metadata); - // If there is no explicit status code, check if it should be 204 if (statusCodes.length === 0) { if (isErrorModel(program, responseType)) { @@ -151,6 +148,19 @@ function processResponseType( } } + // Emit a warning if a HEAD response has a body (HTTP spec disallows this) + if (verb === "head" && resolvedBody !== undefined) { + diagnostics.add( + createDiagnostic({ + code: "head-operation-no-body", + target: operation, + }), + ); + } + + // Get response headers + const headers = getResponseHeaders(program, metadata); + // Put them into currentEndpoint.responses for (const statusCode of statusCodes) { // the first model for this statusCode/content type pair carries the diff --git a/packages/http/test/responses.test.ts b/packages/http/test/responses.test.ts index 98a40b72185..2056787c497 100644 --- a/packages/http/test/responses.test.ts +++ b/packages/http/test/responses.test.ts @@ -156,6 +156,52 @@ it("treats content-type as a header for HEAD responses", async () => { deepStrictEqual(Object.keys(response.headers), ["content-type"]); }); +it("emits a warning when HEAD response has a body", async () => { + const [routes, diagnostics] = await getOperationsWithServiceNamespace( + ` + @head + op head(): { @header contentType: "text/plain"; value: string }; + `, + ); + + expectDiagnostics(diagnostics, [ + { + code: "@typespec/http/head-operation-no-body", + severity: "warning", + }, + ]); + strictEqual(routes.length, 1); + const response = routes[0].responses[0].responses[0]; + ok(response.body); + // content-type is in the response headers (treated as a header for HEAD verb) + ok(response.headers["content-type"]); +}); + +it("emits a warning when HEAD @error response has a body", async () => { + const [routes, diagnostics] = await getOperationsWithServiceNamespace( + ` + @head + op listHead(): OkResponse | Error; + + @error + model Error { + @header + contentType: "application/problem+json"; + type: string; + } + `, + ); + + // Should get warning about body in HEAD response + expectDiagnostics(diagnostics, [ + { + code: "@typespec/http/head-operation-no-body", + severity: "warning", + }, + ]); + strictEqual(routes.length, 1); +}); + // Regression test for https://github.com/microsoft/typespec/issues/328 it("empty response model becomes body if it has children", async () => { const [routes, diagnostics] = await getOperationsWithServiceNamespace( diff --git a/packages/http/test/verbs.test.ts b/packages/http/test/verbs.test.ts index 48eb235288d..43e52b3ddae 100644 --- a/packages/http/test/verbs.test.ts +++ b/packages/http/test/verbs.test.ts @@ -9,11 +9,24 @@ describe("specify verb with each decorator", () => { ["@put", "put"], ["@patch", "patch"], ["@delete", "delete"], - ["@head", "head"], ])("%s set verb to %s", async (dec, expected) => { const routes = await getRoutesFor(`${dec} op test(): string;`); expect(routes[0].verb).toBe(expected); }); + + it("@head set verb to head", async () => { + // Use void to avoid triggering the head-verb-body warning + const routes = await getRoutesFor(`@head op test(): void;`); + expect(routes[0].verb).toBe("head"); + }); + + it("@head with body emits head-operation-no-body warning", async () => { + const diagnostics = await diagnoseOperations(`@head op test(): string;`); + expectDiagnostics(diagnostics, { + code: "@typespec/http/head-operation-no-body", + severity: "warning", + }); + }); }); describe("emit error when using 2 verb decorator together on the same node", () => { diff --git a/packages/openapi3/test/tsp-openapi3/paths.test.ts b/packages/openapi3/test/tsp-openapi3/paths.test.ts index 26802c7ad6d..c0bb496b28d 100644 --- a/packages/openapi3/test/tsp-openapi3/paths.test.ts +++ b/packages/openapi3/test/tsp-openapi3/paths.test.ts @@ -948,7 +948,6 @@ model Foo { responses: { [statusCode]: { description: "Test Response", - content: { "application/json": { schema: { $ref: "#/components/schemas/Foo" } } }, } as OpenAPI3Response, }, }, @@ -978,7 +977,6 @@ model Foo { @route("/") @head op headFoo(): { @statusCode statusCode: 100; - @body body: Foo; }; " `); @@ -1155,15 +1153,6 @@ model Foo { }, }, }, - head: { - operationId: "headFoo", - parameters: [], - responses: { - default: { - $ref: "#/components/responses/TestResponse", - }, - }, - }, }, }, }); @@ -1190,11 +1179,6 @@ model Foo { Body = Foo >; - @route("/") @head op headFoo(): GeneratedHelpers.DefaultResponse< - Description = "Base description", - Body = Foo - >; - namespace GeneratedHelpers { @doc(Description) @error