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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/http-client-java"
---

Support XML serialization for models: generate XmlSerializer helper classes and use the XML ObjectSerializer overload of toObject/fromObject in convenience methods for XML request/response bodies.
33 changes: 33 additions & 0 deletions .github/instructions/http-client-java.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,36 @@ Typical task: `add e2e test case for <package>, scenario is <url-to-tsp-file>`.
# Add feature or fix bug

- Run `npm run format` and commit the formatted code before finalizing. Do not include any other changes in the commit.
- Add a changelog entry: `pnpm change add @typespec/http-client-java --kind=<feature|fix> --message="<change-summary>"` (use `feature` for new features, `fix` for bug fixes). Commit the new md file in the ".chronus" folder of the repository root.

# Modify the code generator (emitter + generator e2e loop)

Use this workflow when a change requires modifying how client code is generated (not just adding tests).

The code generator has two connected parts, linked by a `code-model.yaml`:

- `emitter/` — TypeScript. Consumes the TypeSpec compiler output and produces `code-model.yaml`.
- `generator/` — Java. Consumes `code-model.yaml` and emits the Java client source.

After a compile, inspect `tsp-output/**/code-model.yaml` in the test module to see what the emitter passed to the generator.

## Edit → update emitter → test loop

1. Make the emitter (TypeScript) and/or generator (Java) changes.
2. Ensure the generator compiles: `mvn clean install --define spotless:skip --define skipTests --no-transfer-progress -T 1C -f ./generator/pom.xml` (from `<repository-root>/packages/http-client-java`).
3. Ensure the emitter builds: `npm run build:emitter`.
4. Format Java with `mvn spotless:apply --no-transfer-progress -T 1C --activate-profiles test -f ./generator/pom.xml` and TypeScript via `npm run format`.
5. Run `pwsh Setup.ps1` in `generator/http-client-generator-test`. This packs the emitter (bundling the freshly built generator jar) and installs it into the test module. Re-run it after every generator/emitter change you want reflected in generation.
6. Regenerate by compiling a spec. Do NOT hardcode the TypeSpec file name — it varies per feature, and sometimes you must author a new `<scenario>/main.tsp` first. The test module's `tspconfig.yaml` already configures the emitter and its output dir, so a plain compile is enough; output goes to `tsp-output/`:
`npx tsp compile <path-to-tsp>`
(Optionally add `--option "@typespec/http-client-java.emitter-output-dir=$PWD/tsp-output/<name>"` to isolate output into a subfolder for an easier diff.)
7. Verify the generated code under `tsp-output/**/src` is as expected. When the spec corresponds to sources tracked in `src/main/java`, compare against them and, if correct, copy the generated files into `src` (replacing existing files) but EXCLUDE `module-info.java`. Some specs do not map to `src` — in that case just verify the output, without comparing or copying.
8. When the spec maps to `src` and you copied the generated code in, run the tests (`mvn test`, or a targeted `--define "test=<pkg>.<Class>"`). Restart the Spector server if needed (`npm run spector-stop` then `npm run spector-start`). If the spec does not map to `src`, verifying the generated output (step 7) is sufficient.

## Emitting static helper classes from resource templates

Some helpers are shipped verbatim as resource templates rather than built up in code:

- `generator/http-client-generator/src/main/java/.../TypeSpecPlugin.java` — `writeHelperClasses(...)` emits per-client helper classes into the implementation subpackage via `JavaPackage.addJavaFromResources(packageName, resourceName[, fileName])`.
- Resource templates live under `generator/http-client-generator-core/src/main/resources/*.java` and must START with `import` statements — do NOT include a `package` line or the license header. The file factory injects the license header, the `package` statement, and reorders imports. The class name must match the resource/file name.
- Class-name constants live in `generator/http-client-generator-core/src/main/java/.../util/ClientModelUtil.java`.
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ private String expressionConvertFromBinaryData(IType responseBodyType, IType raw
private String expressionMapFromBinaryData(IType responseBodyType, IType rawType, Set<String> mediaTypes,
Set<GenericType> typeReferenceStaticClasses) {
SupportedMimeType mimeType = SupportedMimeType.getResponseKnownMimeType(mediaTypes);
// TODO (weidxu): support XML etc.
String serializerArgument = xmlSerializerArgument(mimeType);
switch (mimeType) {
case TEXT:
String baseHandling = "protocolMethodData.toString()";
Expand All @@ -161,31 +161,33 @@ private String expressionMapFromBinaryData(IType responseBodyType, IType rawType
return null;

default:
// JSON etc.
// JSON, XML etc.
if (responseBodyType instanceof EnumType) {
// enum
return String.format("protocolMethodData -> %1$s.from%2$s(protocolMethodData.toObject(%2$s.class))",
responseBodyType, ((EnumType) responseBodyType).getElementType());
return String.format(
"protocolMethodData -> %1$s.from%2$s(protocolMethodData.toObject(%2$s.class%3$s))",
responseBodyType, ((EnumType) responseBodyType).getElementType(), serializerArgument);
} else if (responseBodyType instanceof GenericType) {
// generic, e.g. list, map
typeReferenceStaticClasses.add((GenericType) responseBodyType);
return String.format("protocolMethodData -> protocolMethodData.toObject(%1$s)",
TemplateUtil.getTypeReferenceCreation(responseBodyType));
return String.format("protocolMethodData -> protocolMethodData.toObject(%1$s%2$s)",
TemplateUtil.getTypeReferenceCreation(responseBodyType), serializerArgument);
} else if (responseBodyType == ClassType.BINARY_DATA) {
// BinaryData, no need to do the map in expressionConvertFromBinaryData
return null;
} else if (responseBodyType == ArrayType.BYTE_ARRAY) {
// byte[]
if (rawType == ClassType.BASE_64_URL) {
return "protocolMethodData -> protocolMethodData.toObject(" + ClassType.BASE_64_URL.getName()
+ ".class).decodedBytes()";
+ ".class" + serializerArgument + ").decodedBytes()";
} else {
return "protocolMethodData -> protocolMethodData.toObject(byte[].class)";
return "protocolMethodData -> protocolMethodData.toObject(byte[].class" + serializerArgument
+ ")";
}
} else {
// default, treat as class
return "protocolMethodData -> protocolMethodData.toObject(" + responseBodyType.asNullable()
+ ".class)";
+ ".class" + serializerArgument + ")";
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,66 @@

abstract class ConvenienceMethodTemplateBase {

// Name of the static ObjectSerializer member used for XML serialization on the convenience client.
static final String XML_SERIALIZER_MEMBER_NAME = "SERIALIZER";

protected ConvenienceMethodTemplateBase() {
}

/**
* Whether the XML {@link com.azure.core.util.serializer.ObjectSerializer} overload should be used for the given
* MIME type. XML serialization via an explicit serializer is only required for the azure-core (v1) flavor.
*
* @param mimeType the MIME type.
* @return whether to use the XML serializer overload of {@code toObject}/{@code fromObject}.
*/
static boolean useXmlObjectSerializer(SupportedMimeType mimeType) {
return mimeType == SupportedMimeType.XML && JavaSettings.getInstance().isAzureV1();
}

/**
* The additional argument (e.g. {@code ", SERIALIZER"}) to append to {@code toObject}/{@code fromObject} calls when
* the XML serializer overload should be used, or an empty string otherwise.
*
* @param mimeType the MIME type.
* @return the serializer argument, possibly empty.
*/
static String xmlSerializerArgument(SupportedMimeType mimeType) {
return useXmlObjectSerializer(mimeType) ? ", " + XML_SERIALIZER_MEMBER_NAME : "";
}

/**
* Whether any of the convenience methods requires XML serialization (request or response). Used to decide whether
* the convenience client needs a static XML serializer member. Only applicable to the azure-core (v1) flavor.
*
* @param convenienceMethods the convenience methods on the client.
* @return whether a static XML serializer member is required.
*/
public boolean useXmlSerializerMember(Collection<ConvenienceMethod> convenienceMethods) {
if (!JavaSettings.getInstance().isAzureV1() || convenienceMethods == null) {
return false;
}
for (ConvenienceMethod convenienceMethod : convenienceMethods) {
if (!isMethodIncluded(convenienceMethod)) {
continue;
}
// getResponseKnownMimeType simply parses a MIME string, so it is reused here for the request content type.
String requestContentType = convenienceMethod.getProtocolMethod().getProxyMethod().getRequestContentType();
if (requestContentType != null
&& SupportedMimeType.getResponseKnownMimeType(List.of(requestContentType)) == SupportedMimeType.XML) {
return true;
}
Set<String> responseContentTypes
= convenienceMethod.getProtocolMethod().getProxyMethod().getResponseContentTypes();
if (responseContentTypes != null
&& !responseContentTypes.isEmpty()
&& SupportedMimeType.getResponseKnownMimeType(responseContentTypes) == SupportedMimeType.XML) {
return true;
}
}
return false;
}

public void write(ConvenienceMethod convenienceMethodObj, JavaClass classBlock,
Set<GenericType> typeReferenceStaticClasses) {
if (!isMethodIncluded(convenienceMethodObj)) {
Expand Down Expand Up @@ -569,17 +626,19 @@ private static String expressionConvertToBinaryData(String name, IType type, Str
return name;

default:
// JSON etc.
// JSON, XML etc.
String serializerArgument = xmlSerializerArgument(mimeType);
if (type == ClassType.BINARY_DATA) {
return name;
} else {
if (type == ClassType.BASE_64_URL) {
return "BinaryData.fromObject(" + ClassType.BASE_64_URL.getName() + ".encode(" + name + "))";
return "BinaryData.fromObject(" + ClassType.BASE_64_URL.getName() + ".encode(" + name + ")"
+ serializerArgument + ")";
} else if (type instanceof EnumType) {
return "BinaryData.fromObject(" + name + " == null ? null : " + name + "."
+ ((EnumType) type).getToMethodName() + "())";
+ ((EnumType) type).getToMethodName() + "()" + serializerArgument + ")";
} else {
return "BinaryData.fromObject(" + name + ")";
return "BinaryData.fromObject(" + name + serializerArgument + ")";
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ private boolean isResponseBase(IType type) {
private String expressionConvertFromBinaryData(IType responseBodyType, IType rawType, String invocationExpression,
Set<String> mediaTypes, Set<GenericType> typeReferenceStaticClasses) {
SupportedMimeType mimeType = SupportedMimeType.getResponseKnownMimeType(mediaTypes);
// TODO (weidxu): support XML etc.
String serializerArgument = xmlSerializerArgument(mimeType);
switch (mimeType) {
case TEXT:
String basicText = invocationExpression + ".toString()";
Expand All @@ -219,32 +219,33 @@ private String expressionConvertFromBinaryData(IType responseBodyType, IType raw
return invocationExpression;

default:
// JSON etc.
// JSON, XML etc.
if (responseBodyType instanceof EnumType) {
// enum
IType elementType = ((EnumType) responseBodyType).getElementType();
return String.format("%1$s.from%2$s(%3$s.toObject(%2$s.class))", responseBodyType, elementType,
invocationExpression);
return String.format("%1$s.from%2$s(%3$s.toObject(%2$s.class%4$s))", responseBodyType, elementType,
invocationExpression, serializerArgument);
} else if (responseBodyType instanceof GenericType) {
// generic, e.g. List, Map
typeReferenceStaticClasses.add((GenericType) responseBodyType);
return String.format("%2$s.toObject(%1$s)", TemplateUtil.getTypeReferenceCreation(responseBodyType),
invocationExpression);
return String.format("%2$s.toObject(%1$s%3$s)",
TemplateUtil.getTypeReferenceCreation(responseBodyType), invocationExpression,
serializerArgument);
} else if (responseBodyType == ClassType.BINARY_DATA) {
// BinaryData
return invocationExpression;
} else if (responseBodyType == ArrayType.BYTE_ARRAY) {
// byte[]
if (rawType == ClassType.BASE_64_URL) {
return invocationExpression + ".toObject(" + ClassType.BASE_64_URL.getName()
+ ".class).decodedBytes()";
return invocationExpression + ".toObject(" + ClassType.BASE_64_URL.getName() + ".class"
+ serializerArgument + ").decodedBytes()";
} else {
return invocationExpression + ".toObject(byte[].class)";
return invocationExpression + ".toObject(byte[].class" + serializerArgument + ")";
}
} else {
// default, treat as class
return String.format("%2$s.toObject(%1$s.class)", responseBodyType.asNullable(),
invocationExpression);
return String.format("%2$s.toObject(%1$s.class%3$s)", responseBodyType.asNullable(),
invocationExpression, serializerArgument);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ public final void write(AsyncSyncClient asyncClient, JavaFile javaFile) {

Templates.getConvenienceAsyncMethodTemplate().addImports(imports, asyncClient.getConvenienceMethods());

final boolean useXmlSerializer
= Templates.getConvenienceAsyncMethodTemplate().useXmlSerializerMember(asyncClient.getConvenienceMethods());
if (useXmlSerializer) {
imports.add("com.azure.core.util.serializer.ObjectSerializer");
imports.add(settings.getPackage(settings.getImplementationSubpackage()) + "."
+ ClientModelUtil.XML_SERIALIZER_PROVIDERS_CLASS_NAME);
}

javaFile.declareImport(imports);
javaFile.javadocComment(comment -> comment.description(String
.format("Initializes a new instance of the asynchronous %1$s type.", serviceClient.getInterfaceName())));
Expand All @@ -90,6 +98,12 @@ public final void write(AsyncSyncClient asyncClient, JavaFile javaFile) {
}
javaFile.publicFinalClass(asyncClassName, classBlock -> {
// Add service client member variable
if (useXmlSerializer) {
addGeneratedAnnotation(classBlock);
classBlock.privateStaticFinalVariable(
"ObjectSerializer " + ConvenienceMethodTemplateBase.XML_SERIALIZER_MEMBER_NAME + " = "
+ ClientModelUtil.XML_SERIALIZER_PROVIDERS_CLASS_NAME + ".createInstance()");
}
addGeneratedAnnotation(classBlock);
if (wrapServiceClient) {
classBlock.privateFinalMemberVariable(serviceClient.getClassName(), "serviceClient");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ public final void write(AsyncSyncClient syncClient, JavaFile javaFile) {

Templates.getConvenienceSyncMethodTemplate().addImports(imports, syncClient.getConvenienceMethods());

if (useXmlSerializerMember(syncClient)) {
imports.add("com.azure.core.util.serializer.ObjectSerializer");
imports.add(settings.getPackage(settings.getImplementationSubpackage()) + "."
+ ClientModelUtil.XML_SERIALIZER_PROVIDERS_CLASS_NAME);
}

if (!JavaSettings.getInstance().isAzureV1()) {
ClassType.INSTRUMENTATION.addImportsTo(imports, false);
ClassType.SDK_INSTRUMENTATION_OPTIONS.addImportsTo(imports, false);
Expand Down Expand Up @@ -107,6 +113,12 @@ protected void writeClass(AsyncSyncClient syncClient, JavaClass classBlock, Java
final boolean wrapServiceClient = methodGroupClient == null;

// Add service client member
if (useXmlSerializerMember(syncClient)) {
addGeneratedAnnotation(classBlock);
classBlock.privateStaticFinalVariable(
"ObjectSerializer " + ConvenienceMethodTemplateBase.XML_SERIALIZER_MEMBER_NAME + " = "
+ ClientModelUtil.XML_SERIALIZER_PROVIDERS_CLASS_NAME + ".createInstance()");
}
addGeneratedAnnotation(classBlock);
if (wrapServiceClient) {
classBlock.privateFinalMemberVariable(serviceClient.getClassName(), "serviceClient");
Expand Down Expand Up @@ -206,6 +218,10 @@ protected void addGeneratedAnnotation(JavaContext classBlock) {
classBlock.annotation(Annotation.GENERATED.getName());
}

private static boolean useXmlSerializerMember(AsyncSyncClient syncClient) {
return Templates.getConvenienceSyncMethodTemplate().useXmlSerializerMember(syncClient.getConvenienceMethods());
}

private void writeConvenienceMethods(List<ConvenienceMethod> convenienceMethods, JavaClass classBlock) {
Set<GenericType> typeReferenceStaticClasses = new LinkedHashSet<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ public class ClientModelUtil {
public static final String MULTI_PART_FORM_DATA_HELPER_CLASS_NAME = "MultipartFormDataHelper";
public static final String GENERIC_MULTI_PART_FORM_DATA_HELPER_CLASS_NAME = "GenericMultipartFormDataHelper";

public static final String XML_SERIALIZER_CLASS_NAME = "XmlSerializer";
public static final String XML_SERIALIZER_PROVIDERS_CLASS_NAME = "XmlSerializerProviders";

private static final Pattern SPLIT_FLATTEN_PROPERTY_PATTERN = Pattern.compile("((?<!\\\\))\\.");

public static final String JSON_MERGE_PATCH_HELPER_CLASS_NAME = "JsonMergePatchHelper";
Expand Down
Loading
Loading