Skip to content

kotlin codegen: nested transferable references a serializer that is never generated #548

Description

@mattcreaser

Summary

When a transferable appears as a field of an operation-scoped result record (rather than as the method's return type), the Kotlin generator emits a @Serializable(with = ...) annotation pointing at a serializer object that it never generates. Serializers.kt is not emitted at all, and nothing else declares the referenced name, so the generated client does not compile.

This is not specific to third-party or unrecognized transferables — it reproduces with realtime/channel.

Reproduction

Add a fixture at native/codegen-fixtures/99-repro/spec.json:

{
  "openrpc": "1.3.2",
  "info": { "title": "test", "version": "1.0.0" },
  "methods": [
    {
      "name": "api.getSession",
      "params": [],
      "result": {
        "name": "GetSessionResult",
        "schema": {
          "type": "object",
          "properties": {
            "sessionId": { "type": "string" },
            "channel": {
              "x-blocks-transferable": "realtime/channel",
              "x-blocks-type-args": [
                { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] }
              ]
            }
          },
          "required": ["sessionId", "channel"]
        }
      }
    }
  ]
}

Then:

cd native/kotlin && ./gradlew :codegen:regenerateFixtures --rerun-tasks

Actual result

Two files are generated — Api.kt and Servers.kt. No Serializers.kt.

Api.kt:

package com.example.app

import com.aws.blocks.kotlin.BlocksClient
import com.aws.blocks.kotlin.BlocksRequest
import com.aws.blocks.kotlin.BlocksServer
import com.aws.blocks.kotlin.json.BlocksJson
import com.aws.blocks.kotlin.realtime.RealtimeChannel
import kotlin.String
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.decodeFromJsonElement

public class Api(
  private val server: BlocksServer = Servers.local,
) {
  private val client: BlocksClient = BlocksClient(server)

  public suspend fun getSession(): GetSession.Result {
    val request = BlocksRequest(method = "api.getSession", params = emptyList(), id = BlocksRequest.nextId())
    val result = client.execute(request)
    return BlocksJson.decodeFromJsonElement(result)
  }

  public object GetSession {
    @Serializable
    public data class Result(
      public val sessionId: String,
      @Serializable(with = RealtimeChannelChannelSerializer::class)
      public val channel: RealtimeChannel<Result.Channel>,
    ) {
      @Serializable
      public data class Channel(
        public val text: String,
      )
    }
  }
}

RealtimeChannelChannelSerializer is referenced unqualified on line 27, is not imported, and is not declared by any generated file.

Expected result

Either a Serializers.kt containing the referenced serializer object, or no annotation referring to one.

Cause

Two code paths disagree about which transferables exist.

The annotation is emitted while walking a record's fields, so it fires for any transferable-typed field (KotlinCodeGenerator.kt, in the record-emission path around the isTransferableType(field.type) check).

The serializer objects come from collectTransferableEntries, which walks only model.typeDefinitions:

for (typeDef in model.typeDefinitions) {
    when (val t = typeDef.type) {
        is ResolvedType.Record -> t.fields.forEach { visit(it.type) }
        else -> {}
    }
}

GetSession.Result is scoped to the operation and is emitted through the operation's nested-type path, not registered as a shared type definition. Confirmation from the same run: no Types.kt is generated for this spec, which means the type index — built from model.typeDefinitions — held no records at all. So collectTransferableEntries returns empty, and generate skips Serializers.kt because transferableEntries.isNotEmpty() is false.

Scope

Any transferable nested in an operation-scoped record. The same shape with an unrecognized tag produces the same dangling reference under the name UnknownLinkSerializer (the generated serializer name for an unrecognized tag is Unknown plus a suffix derived from the type arguments, with the tag itself omitted — so two unrecognized tags with matching type arguments would also collapse to one name once that path emits real code).

Suggested direction

Collect the serializer entries from the same traversal that emits the annotation, so the two cannot diverge — or extend the collection to include operation-scoped nested records. Keying entries by tag rather than by a name derived only from type arguments would also remove the collision noted above.

Environment

  • main @ 6496713695aa75706ea174172efe6eddad4920f4
  • Verified via :codegen:regenerateFixtures

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingkotlinIssues or Pull Requests related to Kotlin codepending-maintainer-responseWaiting on a response from the maintainerspending-triageIssue is pending triage by the maintainers

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions