Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .chronus/changes/file-binary-options-2026-2-2-3-52-5.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@azure-tools/typespec-client-generator-core"
---

Add full support for `Http.File` type with `BinarySerializationOptions` containing `isText`, `contentTypes`, and `filename` properties.
22 changes: 5 additions & 17 deletions packages/typespec-client-generator-core/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { $ } from "@typespec/compiler/typekit";
import {
HttpOperation,
HttpOperationHeaderParameter,
HttpOperationParameter,
HttpOperationPathParameter,
HttpOperationQueryParameter,
Expand Down Expand Up @@ -48,7 +49,6 @@ import {
SdkQueryParameter,
SdkServiceResponseHeader,
SdkType,
SerializationOptions,
TCGCContext,
} from "./interfaces.js";
import {
Expand Down Expand Up @@ -195,14 +195,6 @@ function getSdkHttpParameters(
const bodyParam = diagnostics.pipe(
getSdkHttpParameter(context, tspBody.property, httpOperation.operation, undefined, "body"),
);
if (
tspBody.bodyKind === "file" &&
bodyParam.kind === "body" &&
bodyParam.type.kind === "model"
) {
bodyParam.type.serializationOptions = bodyParam.type.serializationOptions || {};
bodyParam.type.serializationOptions.binary = { isFile: true };
}
if (bodyParam.kind !== "body") {
diagnostics.add(
createDiagnostic({
Expand Down Expand Up @@ -514,7 +506,10 @@ export function getSdkHttpParameter(
return diagnostics.wrap({
...headerQueryBase,
kind: "header",
serializedName: getHeaderFieldName(program, param) ?? base.name,
serializedName:
getHeaderFieldName(program, param) ??
(httpParam as HttpOperationHeaderParameter)?.name ??
base.name,
});
}

Expand All @@ -533,7 +528,6 @@ function getSdkHttpResponseAndExceptions(
const diagnostics = createDiagnosticCollector();
const responses: SdkHttpResponse[] = [];
const exceptions: SdkHttpErrorResponse[] = [];
let serializationOptions: SerializationOptions = {};
for (const response of httpOperation.responses) {
const headers: SdkServiceResponseHeader[] = [];
let body: Type | undefined;
Expand All @@ -557,9 +551,6 @@ function getSdkHttpResponseAndExceptions(
context.__responseHeaderCache.set(header, headers[headers.length - 1]);
}
if (innerResponse.body && !isNeverOrVoidType(innerResponse.body.type)) {
if (innerResponse.body.bodyKind === "file") {
serializationOptions = { binary: { isFile: true } };
}
if (body && body !== innerResponse.body.type) {
diagnostics.add(
createDiagnostic({
Expand Down Expand Up @@ -588,9 +579,6 @@ function getSdkHttpResponseAndExceptions(
addEncodeInfo(context, innerResponse.body.property, type, defaultContentType);
}
}
if (type.kind === "model") {
type.serializationOptions = { ...type.serializationOptions, ...serializationOptions };
}
}
}
const sdkResponse = {
Expand Down
29 changes: 29 additions & 0 deletions packages/typespec-client-generator-core/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,35 @@ export interface XmlSerializationOptions {
export interface BinarySerializationOptions {
/** Whether this is a file/stream input */
isFile: boolean;
/**
* Whether the file contents should be represented as a string or raw byte stream.
*
* True if the `contents` property is a `string`, `false` if it is `bytes`.
*
* Emitters may choose to represent textual files as strings or streams of textual characters.
* If this property is `false`, emitters must expect that the contents may contain non-textual
* data.
*
* This property is only present when `isFile` is `true`. When undefined, it indicates the
* body is not a file type.
*/
isText?: boolean;
/**
* The list of inner media types of the file. In other words, what kind of files can be returned.
*
* This is determined by the `contentType` property of the file model.
*
* This property is only present when `isFile` is `true`. When undefined, it indicates the
* body is not a file type.
*/
contentTypes?: string[];
/**
* The ModelProperty that represents the filename in the file model.
*
* This property is only present when `isFile` is `true`. When undefined, it indicates the
* body is not a file type.
*/
filename?: ModelProperty;
}

/**
Expand Down
25 changes: 23 additions & 2 deletions packages/typespec-client-generator-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ import {
} from "@typespec/compiler";
import {
Authentication,
HttpOperationFileBody,
HttpOperationMultipartBody,
HttpPayloadBody,
Visibility,
getAuthentication,
getServers,
Expand Down Expand Up @@ -1652,7 +1654,7 @@ function updateTypesFromOperation(
}

// add serialization options to model type
updateSerializationOptions(context, sdkType, httpBody.contentTypes);
updateSerializationOptions(context, sdkType, httpBody.contentTypes, undefined, httpBody);

// after completion of usage calculation for httpBody, check whether it has
// conflicting usage between multipart and regular body
Expand Down Expand Up @@ -1714,7 +1716,13 @@ function updateTypesFromOperation(
}

// add serialization options to model type
updateSerializationOptions(context, sdkType, innerResponse.body.contentTypes);
updateSerializationOptions(
context,
sdkType,
innerResponse.body.contentTypes,
undefined,
innerResponse.body,
);
}
const access = getAccessOverride(context, operation) ?? "public";
diagnostics.pipe(updateUsageOrAccess(context, access, sdkType));
Expand Down Expand Up @@ -2050,6 +2058,7 @@ function updateSerializationOptions(
type: SdkType,
contentTypes: string[],
options?: PropagationOptions,
httpBody?: HttpPayloadBody,
) {
options = options ?? {};
options.seenTypes = options.seenTypes ?? new Set<SdkType>();
Expand Down Expand Up @@ -2082,6 +2091,18 @@ function updateSerializationOptions(
return;
}

// Handle file body serialization - if it's a file, set binary options and skip json/xml
if (httpBody?.bodyKind === "file") {
const fileBody = httpBody as HttpOperationFileBody;
type.serializationOptions.binary = {
isFile: true,
isText: fileBody.isText,
contentTypes: fileBody.contentTypes,
filename: fileBody.filename,
};
return; // No need to add json/xml serialization for file types
}

setSerializationOptions(context, type, contentTypes);
for (const property of type.properties) {
if (property.kind === "property") {
Expand Down
127 changes: 126 additions & 1 deletion packages/typespec-client-generator-core/test/methods/file.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ok, strictEqual } from "assert";
import { deepStrictEqual, ok, strictEqual } from "assert";
import { it } from "vitest";
import { createSdkContextForTester, SimpleTester, SimpleTesterWithService } from "../tester.js";

Expand All @@ -23,6 +23,10 @@ it("basic file input", async () => {
ok(bodyParam);
strictEqual(bodyParam.type.kind, "model");
strictEqual(bodyParam.type.serializationOptions.binary?.isFile, true);
strictEqual(bodyParam.type.serializationOptions.binary?.isText, false);
deepStrictEqual(bodyParam.type.serializationOptions.binary?.contentTypes, ["*/*"]);
ok(bodyParam.type.serializationOptions.binary?.filename);
strictEqual(bodyParam.type.serializationOptions.binary?.filename.name, "filename");
const fileModel = context.sdkPackage.models.find((m) => m.name === "File");
ok(fileModel);
strictEqual(fileModel.properties.length, 3);
Expand Down Expand Up @@ -53,6 +57,10 @@ it("file input with content type", async () => {
ok(bodyParam);
strictEqual(bodyParam.type.kind, "model");
strictEqual(bodyParam.type.serializationOptions.binary?.isFile, true);
strictEqual(bodyParam.type.serializationOptions.binary?.isText, false);
deepStrictEqual(bodyParam.type.serializationOptions.binary?.contentTypes, ["application/yaml"]);
ok(bodyParam.type.serializationOptions.binary?.filename);
strictEqual(bodyParam.type.serializationOptions.binary?.filename.name, "filename");
const fileModel = bodyParam.type;
const contentType = fileModel.properties.find((p) => p.name === "contentType")!;
strictEqual(contentType.type.kind, "constant");
Expand Down Expand Up @@ -85,6 +93,10 @@ it("basic file output", async () => {
ok(fileModel.properties.find((p) => p.name === "filename"));
ok(responseBody.type);
strictEqual(responseBody.type.serializationOptions.binary?.isFile, true);
strictEqual(responseBody.type.serializationOptions.binary?.isText, false);
deepStrictEqual(responseBody.type.serializationOptions.binary?.contentTypes, ["*/*"]);
ok(responseBody.type.serializationOptions.binary?.filename);
strictEqual(responseBody.type.serializationOptions.binary?.filename.name, "filename");
});

it("self-defined file", async () => {
Expand Down Expand Up @@ -126,6 +138,13 @@ it("self-defined file", async () => {
ok(uploadBodyParam);
strictEqual(uploadBodyParam.type, specFile);
strictEqual(uploadBodyParam.type.serializationOptions.binary?.isFile, true);
strictEqual(uploadBodyParam.type.serializationOptions.binary?.isText, true);
deepStrictEqual(uploadBodyParam.type.serializationOptions.binary?.contentTypes, [
"application/json",
"application/yaml",
]);
ok(uploadBodyParam.type.serializationOptions.binary?.filename);
strictEqual(uploadBodyParam.type.serializationOptions.binary?.filename.name, "filename");
const uploadHeaderParam = uploadHttpOperation.parameters.find(
(p) => p.serializedName === "x-filename",
);
Expand All @@ -140,4 +159,110 @@ it("self-defined file", async () => {
ok(downloadResponse);
strictEqual(downloadResponse.type, specFile);
strictEqual(downloadResponse.type.serializationOptions.binary?.isFile, true);
strictEqual(downloadResponse.type.serializationOptions.binary?.isText, true);
deepStrictEqual(downloadResponse.type.serializationOptions.binary?.contentTypes, [
"application/json",
"application/yaml",
]);
ok(downloadResponse.type.serializationOptions.binary?.filename);
strictEqual(downloadResponse.type.serializationOptions.binary?.filename.name, "filename");
});

it("text file input", async () => {
const { program } = await SimpleTester.compile(
`
@service
namespace TestService {
op uploadTextFile(@body file: File<"text/plain", string>): void;
}
`,
);
const context = await createSdkContextForTester(program);
const sdkPackage = context.sdkPackage;
const method = sdkPackage.clients[0].methods[0];
strictEqual(method.name, "uploadTextFile");
const httpOperation = method.operation;
const bodyParam = httpOperation.bodyParam;
ok(bodyParam);
strictEqual(bodyParam.type.kind, "model");
strictEqual(bodyParam.type.serializationOptions.binary?.isFile, true);
strictEqual(bodyParam.type.serializationOptions.binary?.isText, true);
deepStrictEqual(bodyParam.type.serializationOptions.binary?.contentTypes, ["text/plain"]);
ok(bodyParam.type.serializationOptions.binary?.filename);
strictEqual(bodyParam.type.serializationOptions.binary?.filename.name, "filename");
});

it("text file output", async () => {
const { program } = await SimpleTester.compile(
`
@service
namespace TestService {
op downloadTextFile(): File<"text/plain", string>;
}
`,
);
const context = await createSdkContextForTester(program);
const sdkPackage = context.sdkPackage;
const method = sdkPackage.clients[0].methods[0];
strictEqual(method.name, "downloadTextFile");
const httpOperation = method.operation;
const responseBody = httpOperation.responses[0];
ok(responseBody);
ok(responseBody.type);
strictEqual(responseBody.type.kind, "model");
strictEqual(responseBody.type.serializationOptions.binary?.isFile, true);
strictEqual(responseBody.type.serializationOptions.binary?.isText, true);
deepStrictEqual(responseBody.type.serializationOptions.binary?.contentTypes, ["text/plain"]);
ok(responseBody.type.serializationOptions.binary?.filename);
strictEqual(responseBody.type.serializationOptions.binary?.filename.name, "filename");
});

it("binary file with multiple content types", async () => {
const { program } = await SimpleTester.compile(
`
@service
namespace TestService {
op uploadImage(@body file: File<"image/png" | "image/jpeg">): void;
}
`,
);
const context = await createSdkContextForTester(program);
const sdkPackage = context.sdkPackage;
const method = sdkPackage.clients[0].methods[0];
strictEqual(method.name, "uploadImage");
const httpOperation = method.operation;
const bodyParam = httpOperation.bodyParam;
ok(bodyParam);
strictEqual(bodyParam.type.kind, "model");
strictEqual(bodyParam.type.serializationOptions.binary?.isFile, true);
strictEqual(bodyParam.type.serializationOptions.binary?.isText, false);
deepStrictEqual(bodyParam.type.serializationOptions.binary?.contentTypes, [
"image/png",
"image/jpeg",
]);
ok(bodyParam.type.serializationOptions.binary?.filename);
strictEqual(bodyParam.type.serializationOptions.binary?.filename.name, "filename");
});

it("file type headers should have correct serializedName", async () => {
const { program } = await SimpleTester.compile(
`
@service
namespace TestService {
op uploadXml(@body file: File<"application/xml">): void;
}
`,
);
const context = await createSdkContextForTester(program);
const sdkPackage = context.sdkPackage;
const method = sdkPackage.clients[0].methods[0];
strictEqual(method.name, "uploadXml");
const httpOperation = method.operation;
// Find Content-Type header parameter
const contentTypeParam = httpOperation.parameters.find(
(p) => p.kind === "header" && p.name === "contentType",
);
ok(contentTypeParam);
// The serializedName should be "Content-Type", not "contentType"
strictEqual(contentTypeParam.serializedName, "Content-Type");
});
Loading