feature: Add RPC client code generation with sbt 2 plugin#462
Conversation
Phase 1 of HTTP client codegen: a JVM library that generates type-safe RPC client code from Scala 3 traits using TASTy Inspector. Components: - HttpClientIR: IR data model (ServiceDef, MethodDef, TypeRef, etc.) - TastyServiceScanner: Reads .tasty files to extract service metadata - RPCClientGenerator: Generates sync/async client source from ServiceDef - HttpCodeGenerator: Orchestrator with config parsing and file caching Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the uni-http-codegen module, which generates type-safe RPC client code from Scala 3 traits using TASTy inspection. The implementation includes an intermediate representation (IR) for service definitions, a scanner to extract metadata from compiled traits, and generators for both synchronous and asynchronous clients. Review feedback suggests optimizing the generated code by decoding JSON directly from values rather than strings to avoid unnecessary roundtrips, ensuring the file writer correctly returns None when no changes are detected as documented, and cleaning up unused variables in the scanner.
| sb.append(s" def ${method.name}${paramList}: ${returnType} =\n") | ||
| sb.append(generateRequestBody(method)) | ||
| sb.append(s" val resp = client.send(req)\n") | ||
| sb.append(s" Weaver.of[${returnType}].fromJson(resp.contentAsString.getOrElse(\"\"))\n") |
There was a problem hiding this comment.
Avoid unnecessary serialization-deserialization roundtrips. Instead of converting the response to a string and parsing it, use a method that can directly decode from the JSONValue. Additionally, handle empty responses explicitly to avoid cryptic parsing errors.
sb.append(s" Weaver.of[${returnType}].fromJson(resp.contentAsJsonValue.getOrElse(throw new IllegalStateException(\"Empty response body\")))\n")References
- Avoid unnecessary serialization-deserialization roundtrips when processing JSON values. If a JSON value is already parsed, use a method that can directly decode from the JSONValue instead of converting it to a string and then parsing it again.
| sb.append( | ||
| s" client.send(req).map(resp => Weaver.of[${returnType}].fromJson(resp.contentAsString.getOrElse(\"\")))\n" | ||
| ) |
There was a problem hiding this comment.
Avoid unnecessary serialization-deserialization roundtrips. Use a method that decodes directly from the JSONValue. Also, fail fast with an explicit error if the response body is empty to provide clearer debugging information.
sb.append(
s" client.send(req).map(resp => Weaver.of[${returnType}].fromJson(resp.contentAsJsonValue.getOrElse(throw new IllegalStateException(\"Empty response body\"))))\n"
)References
- Avoid unnecessary serialization-deserialization roundtrips when processing JSON values. If a JSON value is already parsed, use a method that can directly decode from the JSONValue instead of converting it to a string and then parsing it again.
| Some(file) | ||
| else | ||
| debug(s"Unchanged: ${file.getAbsolutePath}") | ||
| Some(file) |
There was a problem hiding this comment.
| val (returnType, isAsync) = | ||
| if rawReturnType.fullName == "wvlet.uni.rx.Rx" && rawReturnType.typeArgs.nonEmpty then | ||
| (rawReturnType.typeArgs.head, true) | ||
| else | ||
| (rawReturnType, false) |
There was a problem hiding this comment.
The isAsync variable is declared but its value is never used. This is considered dead code and can be confusing. It should be removed to improve code clarity.
val returnType =
if rawReturnType.fullName == "wvlet.uni.rx.Rx" && rawReturnType.typeArgs.nonEmpty then
rawReturnType.typeArgs.head
else
rawReturnType- Remove unused classpath parameter from TastyServiceScanner - Replace SHA-256 hash comparison with direct string equality in writeIfChanged - Move excludedMethodNames to companion object to avoid per-call allocation - Remove dead normalizedShort no-op logic - Remove redundant comments in RPCClientGenerator Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
/gemini review |
- Use fromJSONValue instead of fromJson to avoid double JSON parsing - Add explicit error for empty response bodies in generated clients - Fix writeIfChanged to return None for unchanged files (doc alignment) - Remove unused isAsync variable in TastyServiceScanner Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces the uni-http-codegen module, which provides a framework for generating type-safe RPC client code from Scala 3 traits using TASTy inspection. Key components include an IR model, a TASTy-based service scanner, and an RPC client source generator. Feedback highlights the need to use fully qualified names in generated code to prevent scoping issues, the importance of adding success checks for HTTP responses before JSON parsing, and a logic correction in the file-writing utility to correctly signal when files remain unchanged.
| def render: String = | ||
| if typeArgs.isEmpty then | ||
| shortName | ||
| else | ||
| s"${shortName}[${typeArgs.map(_.render).mkString(", ")}]" |
There was a problem hiding this comment.
The render method currently uses shortName, which can lead to compilation errors in the generated code if the types are not in the same package as the generated client and no explicit imports are provided. For a code generator, it is safer to use fully qualified names (fullName) to ensure the generated source is valid regardless of the target package.
def render: String =
if typeArgs.isEmpty then
fullName
else
s"${fullName}[${typeArgs.map(_.render).mkString(", ")}]"| sb.append(s" def ${method.name}${paramList}: ${returnType} =\n") | ||
| sb.append(generateRequestBody(method)) | ||
| sb.append(s" val resp = client.send(req)\n") | ||
| sb.append( |
There was a problem hiding this comment.
The generated code directly attempts to parse the response body without checking if the HTTP request was successful (e.g., checking for a 2xx status code). If the server returns an error (4xx/500), resp.contentAsString might contain an error message or HTML, which will cause Weaver.of[T].fromJson to throw an exception. Consider adding a check for resp.isSuccess (or equivalent) in the generated code. Additionally, avoid unnecessary serialization-deserialization roundtrips by decoding directly from the JSON value if the response is already parsed.
References
- Avoid unnecessary serialization-deserialization roundtrips when processing JSON values. If a JSON value is already parsed, use a method that can directly decode from the JSONValue instead of converting it to a string and then parsing it again.
| Some(file) | ||
| else | ||
| debug(s"Unchanged: ${file.getAbsolutePath}") | ||
| None |
There was a problem hiding this comment.
TypeRef.render now uses short names for well-known types (scala.*, java.lang.*, wvlet.uni.*) and fully-qualified names for user-defined types, ensuring generated code compiles regardless of the target package. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the uni-http-codegen module, which generates type-safe RPC clients from Scala 3 traits using TASTy inspection. The implementation includes an intermediate representation (IR) for service definitions, a TASTy scanner for metadata extraction, and a source code generator for sync and async clients. Feedback suggests improving the TASTy scanner's reliability by filtering for specific service traits and handling annotation parameters more robustly. Additionally, it is recommended to use fully qualified type names in the generated code to prevent potential compilation errors related to missing imports.
| */ | ||
| def run(spec: String, tastyFilePath: String, outputDir: File): Option[File] = | ||
| val config = parseConfig(spec) | ||
| val service = TastyServiceScanner.scan(tastyFilePath) |
There was a problem hiding this comment.
The TastyServiceScanner.scan method returns the first trait found in the .tasty file. If a file contains multiple traits, this may result in generating a client for the wrong service. It is recommended to filter the scanned services by the expected config.apiClassName.
val service = TastyServiceScanner.scanAll(List(tastyFilePath)).find(_.fullName == config.apiClassName).getOrElse(throw IllegalStateException(s"Service ${config.apiClassName} not found in ${tastyFilePath}"))| * compiles regardless of the target package. | ||
| */ | ||
| def render: String = | ||
| val name = renderName | ||
| if typeArgs.isEmpty then |
There was a problem hiding this comment.
Using shortName in TypeRef.render can lead to compilation errors in the generated code if the types are not in the same package as the generated client and are not imported. Using fullName ensures that the generated code is self-contained and avoids name resolution issues.
def render: String =
if typeArgs.isEmpty then
fullName
else
s"${fullName}[${typeArgs.map(_.render).mkString(", ")}]"| case Apply(_, List(methodArg, Literal(StringConstant(path)))) => | ||
| val httpMethod = extractHttpMethodFromArg(methodArg) | ||
| (httpMethod, path) |
There was a problem hiding this comment.
The pattern matching on Apply to extract @Endpoint arguments is fragile as it assumes a specific argument order and lacks support for NamedArg. This will fail if the annotation is used with named parameters (e.g., @Endpoint(method = ..., path = ...)). Consider using a more robust way to inspect annotation parameters by checking for NamedArg or using the symbol's parameter names.
Add sbt-uni-codegen plugin targeting sbt 2.0.0-RC10 that generates RPC client code in-process (no forked JVM). Validates the key design hypothesis: sbt 2's Scala 3 metabuild enables direct library calls to uni-http-codegen without the Coursier download + shell process pattern used by sbt-airframe. Includes scripted test that compiles a GreetingService trait, scans its .tasty file, generates a client, and verifies the generated code compiles alongside user code. Also fixes: - JSON string escaping in generated code (proper \" in string literals) - Use summon[Weaver[T]] for primitives, Weaver.of[T] for case classes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Document key design validation: in-process codegen via sbt 2's Scala 3 metabuild works end-to-end. Record Weaver serialization strategy for primitive vs complex types. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port Wadler-style CodeFormatter from wvlet to uni-core (wvlet.uni.text) as a general-purpose text formatting utility. Rewrite RPCClientGenerator to build Doc trees instead of ad-hoc StringBuilder, eliminating the fragile string escaping that caused JSON quote bugs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Let CodeFormatter handle all indentation via nest/newline/ws instead of embedding spaces in text nodes. Extract indentedBlock helper for Scala 3 block pattern (header: + nested body + end marker). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace manual JSON string concatenation with uni's JSON codec API. Generated clients now build request bodies using JSONObject + JSON.parse instead of fragile string escaping. The withJsonContent(JSONValue) overload handles serialization cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add RPCClient class (client-side mirror of RPCRouter) that uses Surface.methodsOf[T] + MethodCodec for serialization at runtime. Generated client code is now much simpler — each method is a one-liner delegating to rpc.callSync/callAsync. No Weaver calls or JSON construction in generated source; all serialization logic lives in RPCClient using the same codec infrastructure as RPCRouter. This paves the way to remove TastyServiceScanner and the scala3-tasty-inspector dependency, since Surface handles all method metadata extraction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace TastyServiceScanner with reflection-based ServiceScanner - Remove scala3-tasty-inspector dependency entirely - Move all codegen code (HttpClientIR, RPCClientGenerator, HttpCodeGenerator) into uni/.jvm (JVM-specific) - sbt plugin now uses ServiceScanner (classloading) instead of TASTy - sbt plugin depends on "uni" instead of "uni-http-codegen" This simplifies the architecture: Surface handles method metadata extraction inline in generated code, reflection finds service classes in the sbt plugin, and no special Scala compiler dependencies are needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Surface-based codegen with reflection scanning, no TASTy inspector, codegen merged into uni module. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Publishes uni locally, then builds the sbt 2 plugin and runs the scripted test that verifies end-to-end code generation + compilation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The sbt-uni-codegen project needs sbt on PATH (not the ./sbt wrapper). Install it via coursier which is available from setup-java. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Plugin Shorter name, leaves room for the plugin to grow beyond just codegen. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
RPCClientruntime (mirrorsRPCRouter), which usesSurface.methodsOf[T]+MethodCodecfor serialization — same codec infrastructure as the server sideCodeFormatter(Wadler's "A Prettier Printer") touni-coreas a general text utilityArchitecture
RPCClient(uni)ServiceScanner(uni/.jvm)RPCClientGenerator(uni/.jvm)UniHttpCodegenPlugin(sbt-uni-codegen/)sourceGeneratorshookCodeFormatter(uni-core)Generated code shape
Test plan
🤖 Generated with Claude Code