Skip to content

openapiv3: proto3 enum zero value is emitted as a legal enum member, and go-http accepts it on input #229

Description

@SebastienMelki

Filed by Claude (via Claude Code) on behalf of @SebastienMelki.

Bug

(sebuf.http.enum_value) correctly pins every named enum variant into the OpenAPI document, but the proto3 zero value (*_UNSPECIFIED = 0) is emitted alongside them as a legal member of the enum array. Since the zero value is an artifact of proto3 requiring a zero slot — not a value the API ever accepts or emits — every generated enum advertises one phantom member.

Worse, the generated Go server accepts it on input, so this is not purely a documentation defect.

Reproduction

syntax = "proto3";
package demo.v1;
import "sebuf/http/annotations.proto";

enum Priority {
  PRIORITY_UNSPECIFIED = 0;                              // proto3 zero slot
  PRIORITY_LOW         = 1 [(sebuf.http.enum_value) = "low"];
  PRIORITY_HIGH        = 2 [(sebuf.http.enum_value) = "high"];
}

message GetTaskRequest  { string id = 1; }
message GetTaskResponse { Priority priority = 1; }

service TaskService {
  rpc GetTask(GetTaskRequest) returns (GetTaskResponse) {
    option (sebuf.http.config) = {
      path: "/tasks/{id}"
      method: HTTP_METHOD_GET
    };
  }
}

Generated OpenAPI:

priority:
  type: string
  enum:
    - PRIORITY_UNSPECIFIED   # <-- phantom
    - low
    - high

Expected:

priority:
  type: string
  enum: [low, high]

The server also accepts the phantom value

The generated UnmarshalJSONSebuf rewrites known custom strings and lets anything else fall through to protojson, which happily accepts proto enum names:

// *_enum_field_encoding.pb.go
if e, ok := priorityFromJSON[s]; ok {
    raw[k], _ = json.Marshal(e.String())
}
// no match -> value passed through unchanged -> protojson accepts "PRIORITY_UNSPECIFIED"

So {"priority": "PRIORITY_UNSPECIFIED"} is accepted by the sebuf server while the upstream API being modelled rejects it. A client generated from this document can construct a request that only the sebuf server tolerates.

Root cause

internal/openapiv3/types.go iterates all enum values with no zero-value filter, in both the string branch (~line 288) and the ENUM_ENCODING_NUMBER branch (~line 267):

for _, value := range field.Enum.Values {
    customValue := annotations.GetEnumValueMapping(value)
    enumValue := customValue
    if enumValue == "" {
        enumValue = string(value.Desc.Name())
    }
    schema.Enum = append(schema.Enum, &yaml.Node{Kind: yaml.ScalarNode, Value: enumValue})
}

Note the fallback: an unannotated value emits its raw proto name. For the zero slot that is almost always wrong, because the whole point of enum_value is that the wire vocabulary differs from the proto vocabulary.

Why this is a problem

  1. Codegen clients get a dead enum member. Every generated enum/union carries a case the server never returns. In TypeScript and Swift this forces an unreachable branch in every exhaustive switch.
  2. It corrupts discriminated unions. Where the zero-value field is a union discriminator, the phantom member becomes a phantom variant, and consumers cannot tell it is not a real state.
  3. Strict validators accept invalid payloads. A request validated against this document passes with the phantom value and is then rejected by the upstream service — the failure surfaces at integration time, not at validation time.
  4. The server disagrees with the API it is modelling (see above), which defeats the point of using sebuf to describe an existing wire format.

Suggested fix

Skip the zero value when emitting enum, unless it is explicitly annotated:

for _, value := range field.Enum.Values {
    customValue := annotations.GetEnumValueMapping(value)
    if value.Desc.Number() == 0 && customValue == "" {
        continue // proto3 zero slot is not part of the wire vocabulary
    }
    ...
}

This keeps the escape hatch: FOO_UNSPECIFIED = 0 [(sebuf.http.enum_value) = "unknown"] still emits "unknown", which is the right behaviour for APIs that genuinely have an unknown state.

Two follow-ups worth considering in the same pass:

Since a zero value with no annotation cannot round-trip today anyway, removing it is a correctness fix rather than a breaking change — but flagging it for a version note.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinggen/go-httpprotoc-gen-go-http (HTTP server)gen/openapiv3protoc-gen-openapiv3 (OpenAPI spec)json-mappingJSON serialization mapping features

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions