Skip to content

feature: Add RPC client code generation with sbt 2 plugin#462

Merged
xerial merged 17 commits into
mainfrom
feature/http-codegen
Apr 7, 2026
Merged

feature: Add RPC client code generation with sbt 2 plugin#462
xerial merged 17 commits into
mainfrom
feature/http-codegen

Conversation

@xerial
Copy link
Copy Markdown
Member

@xerial xerial commented Apr 4, 2026

Summary

  • Add RPC client code generation that produces type-safe client stubs from Scala 3 service traits
  • Generated code delegates to RPCClient runtime (mirrors RPCRouter), which uses Surface.methodsOf[T] + MethodCodec for serialization — same codec infrastructure as the server side
  • sbt 2.0.0-RC10 plugin runs codegen in-process (no forked JVM) thanks to Scala 3 metabuild
  • Add CodeFormatter (Wadler's "A Prettier Printer") to uni-core as a general text utility

Architecture

Component Purpose
RPCClient (uni) Runtime dispatch: Surface + MethodCodec for request/response encoding
ServiceScanner (uni/.jvm) Reflection-based: classloads compiled traits to extract method signatures
RPCClientGenerator (uni/.jvm) Doc-tree code generation via CodeFormatter
UniHttpCodegenPlugin (sbt-uni-codegen/) sbt 2 AutoPlugin with sourceGenerators hook
CodeFormatter (uni-core) Cross-platform Wadler pretty printer

Generated code shape

object UserServiceClient:
  private val rpc = RPCClient.build(
    Surface.of[UserService], Surface.methodsOf[UserService])

  class SyncClient(client: HttpSyncClient):
    def getUser(id: Long): User =
      rpc.callSync[User](client, "getUser", Seq(id))

Test plan

  • 37 unit tests (IR model, config parsing, code generation, service scanning, CodeFormatter)
  • Scripted sbt 2 test: compile API trait → scan → generate → compile generated code
  • All 1374 existing uni tests still pass

🤖 Generated with Claude Code

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>
@github-actions github-actions Bot added doc Improvements or additions to documentation feature New feature labels Apr 4, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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
  1. 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.

Comment on lines +116 to +118
sb.append(
s" client.send(req).map(resp => Weaver.of[${returnType}].fromJson(resp.contentAsString.getOrElse(\"\")))\n"
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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
  1. 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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The documentation for generateAndWrite and run states that None should be returned if the file is unchanged. However, this implementation always returns Some(file). To align with the documentation and provide a clearer signal to callers, this should return None when the file is unchanged.

      None

Comment on lines +136 to +140
val (returnType, isAsync) =
if rawReturnType.fullName == "wvlet.uni.rx.Rx" && rawReturnType.typeArgs.nonEmpty then
(rawReturnType.typeArgs.head, true)
else
(rawReturnType, false)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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>
@xerial
Copy link
Copy Markdown
Member Author

xerial commented Apr 5, 2026

/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>
@xerial
Copy link
Copy Markdown
Member Author

xerial commented Apr 5, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +159 to +163
def render: String =
if typeArgs.isEmpty then
shortName
else
s"${shortName}[${typeArgs.map(_.render).mkString(", ")}]"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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
  1. 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The implementation of writeIfChanged returns Some(file) even when the content is unchanged. This contradicts the method's docstring at line 67, which states it should return None if the file was unchanged. This affects the return value of generateAndWrite and run as well.

      None

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>
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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}"))

Comment on lines +159 to +163
* compiles regardless of the target package.
*/
def render: String =
val name = renderName
if typeArgs.isEmpty then
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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(", ")}]"

Comment on lines +218 to +220
case Apply(_, List(methodArg, Literal(StringConstant(path)))) =>
val httpMethod = extractHttpMethodFromArg(methodArg)
(httpMethod, path)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

xerial and others added 7 commits April 5, 2026 14:49
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>
@xerial xerial changed the title feature: Add uni-http-codegen module with RPC client code generation feature: Add RPC client code generation with sbt 2 plugin Apr 6, 2026
xerial and others added 6 commits April 6, 2026 14:52
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>
@xerial xerial merged commit 63cb7d6 into main Apr 7, 2026
15 checks passed
@xerial xerial deleted the feature/http-codegen branch April 7, 2026 03:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

doc Improvements or additions to documentation feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant