diff --git a/.chronus/changes/copilot-fix-xml-bytes-serialization-2026-2-9-19-20-52.md b/.chronus/changes/copilot-fix-xml-bytes-serialization-2026-2-9-19-20-52.md new file mode 100644 index 00000000000..2baaa4f1092 --- /dev/null +++ b/.chronus/changes/copilot-fix-xml-bytes-serialization-2026-2-9-19-20-52.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http" +--- + +Add warning diagnostic when `bytes` is used as a body type with an XML content type (e.g. `application/xml`). The payload will be treated as raw binary data; use a model type for structured XML serialization. \ No newline at end of file diff --git a/packages/http/src/lib.ts b/packages/http/src/lib.ts index b67083b9e39..de2a9523e5f 100644 --- a/packages/http/src/lib.ts +++ b/packages/http/src/lib.ts @@ -214,6 +214,12 @@ export const $lib = createTypeSpecLibrary({ default: paramMessage`The MergePatch transform does not operate on http envelope metadata. Remove any http metadata decorators ('@query', '@header', '@path', '@cookie', '@statusCode') from the model passed to the MergePatch template. Found '${"metadataType"}' decorating property '${"propertyName"}'`, }, }, + "bytes-xml-body": { + severity: "warning", + messages: { + default: paramMessage`Using 'bytes' as the body type with XML content type '${"contentType"}' will treat the payload as raw binary data rather than structured XML. Use a model type for structured XML serialization.`, + }, + }, }, state: { authentication: { description: "State for the @auth decorator" }, diff --git a/packages/http/src/payload.ts b/packages/http/src/payload.ts index d69d68b57a2..492992d79bd 100644 --- a/packages/http/src/payload.ts +++ b/packages/http/src/payload.ts @@ -93,6 +93,25 @@ export function resolveHttpPayload( ); return diagnostics.wrap({ body: undefined, metadata }); } + + if ( + body.bodyKind === "single" && + body.type.kind === "Scalar" && + isScalarExtendsBytes(body.type) + ) { + for (const ct of body.contentTypes) { + if (isXmlContentType(ct)) { + diagnostics.add( + createDiagnostic({ + code: "bytes-xml-body", + target: body.property ?? type, + format: { contentType: ct }, + }), + ); + break; + } + } + } } return diagnostics.wrap({ body, metadata }); @@ -745,3 +764,20 @@ function resolveContentTypesForBody( } } } + +const xmlContentTypeRegex = /^(application|text)\/(.+\+)?xml$/; + +function isXmlContentType(contentType: string): boolean { + return xmlContentTypeRegex.test(contentType); +} + +function isScalarExtendsBytes(type: Scalar): boolean { + let current: Scalar | undefined = type; + while (current) { + if (current.name === "bytes") { + return true; + } + current = current.baseScalar; + } + return false; +} diff --git a/packages/http/test/parameters.test.ts b/packages/http/test/parameters.test.ts index 91d144cfd6b..95de0da6e7b 100644 --- a/packages/http/test/parameters.test.ts +++ b/packages/http/test/parameters.test.ts @@ -154,6 +154,58 @@ it("emit warning if using contentType parameter without a body", async () => { }); }); +it("emit warning if bytes body has application/xml content type", async () => { + const [_, diagnostics] = await compileOperations(` + @post op post(@header contentType: "application/xml", @body body: bytes): void; + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/http/bytes-xml-body", + severity: "warning", + }); +}); + +it("emit warning if bytes body has text/xml content type", async () => { + const [_, diagnostics] = await compileOperations(` + @post op post(@header contentType: "text/xml", @body body: bytes): void; + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/http/bytes-xml-body", + severity: "warning", + }); +}); + +it("emit warning if bytes body has application/soap+xml content type", async () => { + const [_, diagnostics] = await compileOperations(` + @post op post(@header contentType: "application/soap+xml", @body body: bytes): void; + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/http/bytes-xml-body", + severity: "warning", + }); +}); + +it("emit warning if bytes response body has application/xml content type", async () => { + const [_, diagnostics] = await compileOperations(` + @get op get(): { @header contentType: "application/xml", @body body: bytes }; + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/http/bytes-xml-body", + severity: "warning", + }); +}); + +it("does not emit warning for bytes body with non-xml content type", async () => { + const [_, diagnostics] = await compileOperations(` + @post op post(@header contentType: "application/octet-stream", @body body: bytes): void; + `); + + expectDiagnosticEmpty(diagnostics); +}); + it("resolve body when defined with @body", async () => { const [routes, diagnostics] = await compileOperations(` @get op get(@query select: string, @body bodyParam: string): string;