Skip to content

WebActionFactory produces duplicate JSON variants when gRPC and protobuf POST coexist on same path #3762

@tklovett

Description

@tklovett

Summary

A server that wants to support both gRPC and protobuf POST over HTTP on the same path cannot do so — WebActionsServlet throws IllegalArgumentException at startup due to duplicate JSON variant routing.

Problem

WebActionFactory.transformActionIntoJson() automatically creates a JSON variant of every protobuf-based WebAction:

  • @WireRpc actions: JSON variant changes dispatch from GRPC to POST with JSON media types.
  • @Post + @RequestContentType(APPLICATION_PROTOBUF) actions: JSON variant replaces protobuf media types with JSON equivalents.

Since @WireRpc and @RequestContentType are mutually exclusive on the same function (enforced in Actions.kt), supporting both gRPC and protobuf POST requires two separate WebAction classes. Each independently produces a JSON variant, and the two JSON variants have identical routing:

Action Dispatch Accepted Media Response Content Type
gRPC original GRPC application/grpc application/grpc
gRPC JSON variant POST application/json application/json;charset=utf-8
Protobuf POST original POST application/x-protobuf application/x-protobuf
Protobuf POST JSON variant POST application/json application/json;charset=utf-8

WebActionsServlet's init block checks all bound actions pairwise via BoundAction.hasIdenticalRouting(), finds the two JSON variants identical, and throws:

IllegalArgumentException: Actions [MyGrpcAction, MyProtobufPostAction] have identical routing annotations.

Why existing deduplication doesn't help

collectBoundActions() deduplicates with actions.distinctBy { it.action }, but this only operates within a single WebAction class. It does not deduplicate across separate classes — each class goes through newBoundAction() independently from the WebActionsServlet init block.

No workaround available

Attempt: @WireRpc + @Post without @RequestContentType

Putting both annotations on one function avoids the duplicate routing error (the @Post variant defaults to MediaRange.ALL_MEDIA, so no JSON variant is created for it). However, without @ResponseContentType, the action's responseContentType is null, and ResponseBodyMarshallerFactory cannot find a marshaller for a proto return type:

IllegalArgumentException: no marshaller for null as HelloReply
    at ResponseBodyMarshallerFactory.genericMarshallerFor(ResponseBodyMarshallerFactory.kt:33)
    at ResponseBodyMarshallerFactory.create(ResponseBodyMarshallerFactory.kt:20)
    at ResponseBodyFeatureBinding$Factory.create(ResponseBodyFeatureBinding.kt:122)

Adding @ResponseContentType(APPLICATION_PROTOBUF) fixes the marshaller but re-triggers transformActionIntoJson creating a JSON variant — bringing back the original duplicate routing error.

Prior art: Armeria unframed requests

Armeria handles this with enableUnframedRequests(true) on a GrpcService. A single gRPC service definition automatically supports gRPC, gRPC-Web, JSON POST (application/json), and protobuf POST (application/protobuf) — no separate actions, no variant collisions.

This matters for servers migrating from Armeria to Misk: Armeria clients using protobuf POST (unframed) to call a service will break after that service migrates to Misk, since Misk can't register both gRPC and protobuf POST on the same path.

Possible fixes

  1. Cross-class JSON variant deduplication: Move deduplication from collectBoundActions() (per-class) to WebActionsServlet's init block (global). When two bound actions have identical routing, keep one and discard the duplicate instead of throwing.

  2. Allow suppressing JSON variant creation: Add an annotation (e.g. @NoJsonVariant) that tells transformActionIntoJson to skip creating a JSON variant for a specific action.

  3. Lift the @WireRpc + @RequestContentType restriction: Allow a single function to carry both annotations so one WebAction class can serve gRPC and protobuf POST, with the per-class distinctBy deduplication handling the single JSON variant.

Relevant code

  • WebActionFactory.transformActionIntoJson()misk/src/main/kotlin/misk/web/actions/WebActionFactory.kt
  • WebActionsServlet init block — misk/src/main/kotlin/misk/web/jetty/WebActionsServlet.kt
  • BoundAction.hasIdenticalRouting()misk/src/main/kotlin/misk/web/BoundAction.kt
  • KFunction.asAction()misk/src/main/kotlin/misk/Actions.kt
  • ResponseBodyMarshallerFactorymisk/src/main/kotlin/misk/web/interceptors/ResponseBodyMarshallerFactory.kt

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions