Skip to content

openapiv3: path/query parameters use a shadow type-mapper that drops buf.validate constraints, enums, and int64_encoding #231

Description

@SebastienMelki

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

Bug

protoc-gen-openapiv3 has two independent proto-type → OpenAPI-schema mappers, and path/query parameters go through the impoverished one. The result is that the same field is documented correctly inside components.schemas and incorrectly in the parameters block of the operation that actually uses it.

Everything extractValidationConstraints contributes — enum (from string.in), format, pattern, minLength/maxLength, numeric bounds, const — is silently dropped on parameters. Enum-typed fields and int64_encoding are mishandled too.

Reproduction

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

message ListItemsRequest {
  string collection_id = 1;

  // Allowed values enforced with buf.validate
  string state = 2 [
    (buf.validate.field).string = {in: ["open", "closed", "all"]},
    (sebuf.http.query) = {name: "state"}
  ];

  string email = 3 [
    (buf.validate.field).string.email = true,
    (sebuf.http.query) = {name: "email"}
  ];

  int32 limit = 4 [
    (buf.validate.field).int32 = {gte: 1, lte: 100},
    (sebuf.http.query) = {name: "limit"}
  ];
}

message ListItemsResponse { string id = 1; }

service ItemService {
  rpc ListItems(ListItemsRequest) returns (ListItemsResponse) {
    option (sebuf.http.config) = {
      path: "/collections/{collection_id}/items"
      method: HTTP_METHOD_GET
    };
  }
}

Generated — note the same field documented two different ways:

paths:
  /collections/{collection_id}/items:
    get:
      parameters:
        - name: state
          in: query
          schema: {type: string}            # <-- enum gone
        - name: email
          in: query
          schema: {type: string}            # <-- format: email gone
        - name: limit
          in: query
          schema: {type: integer, format: int32}   # <-- minimum/maximum gone

components:
  schemas:
    ListItemsRequest:
      properties:
        state:
          type: string
          enum: [open, closed, all]         # <-- correct here
        email:
          type: string
          format: email                     # <-- correct here
        limit:
          type: integer
          minimum: 1
          maximum: 100                      # <-- correct here

On a GET, ListItemsRequest is never $ref'd by anything, so the correct schema is dead weight in the document and the wrong one is the only thing a consumer reads.

Root cause

internal/openapiv3/generator.go — both buildPathParameters (~L814) and buildQueryParameters (~L835) call g.createFieldSchema, which delegates to createScalarFieldSchema (~L1076):

func (g *Generator) createScalarFieldSchema(field *protogen.Field) *base.SchemaProxy {
	schema := &base.Schema{}

	switch field.Desc.Kind().String() {
	case headerTypeString:
		schema.Type = []string{headerTypeString}
	case headerTypeInt32, "sint32", "sfixed32":
		schema.Type = []string{headerTypeInteger}
		schema.Format = headerTypeInt32
	case headerTypeInt64, "sint64", "sfixed64":
		// Per proto3 JSON spec, int64 serializes as a string
		schema.Type = []string{headerTypeString}
		schema.Format = headerTypeInt64
	...
	default:
		schema.Type = []string{headerTypeString}
	}

	return base.CreateSchemaProxy(schema)   // <-- never calls extractValidationConstraints
}

This is a parallel reimplementation of the real converter in internal/openapiv3/types.go, which does call extractValidationConstraints at three sites (L43, L225, L323) and has proper handling for enums, maps, timestamps and well-known types.

Three distinct defects follow from the duplication:

  1. No validation constraints. extractValidationConstraints is never reached, so string.inenum (validation.go:120), string.email/uuid/uriformat, pattern, and the int32/int64/float/double gte/lte bounds all vanish.
  2. Enum fields fall to default:. protoreflect's kind string for an enum is "enum", which matches no case, so an enum parameter is emitted as a bare type: string with no values — the enum_value custom strings never appear. (Related to codegen: enum_value custom strings are ignored in URL parameter encoding, so the TS client cannot reach the Go server #219, but a different code path: that issue is about client-side URL encoding; this is the document.)
  3. int64_encoding is ignored. The int64 case hardcodes type: string. A message annotated int64_encoding = NUMBER correctly emits type: integer in components.schemas but type: string as a parameter — the two halves of the same document contradict each other.

Why this is a problem

  1. The parameters block is the part consumers actually use. For GET/DELETE there is no request body, so it is the only description of the input. Being wrong there is worse than being wrong in an unreferenced component.
  2. Self-contradictory documents. Shipping both a correct and an incorrect description of one field means any tool's behaviour depends on which half it reads. Validation middleware built from parameters accepts input the server rejects.
  3. Lost path-parameter constraints are a routing concern, not just docs. Where a {placeholder} is constrained to a small set of values, the document presents it as a free-form string, so a generated client will happily build unroutable URLs.
  4. It makes buf.validate look unsupported. The constraints are implemented and are enforced at runtime — they just do not reach the one place most consumers look.

Note on the enum-in-query workaround

Because sebuf cannot bind a proto enum to a query parameter, the documented workaround is to type such fields as string + buf.validate.string.in. That workaround is exactly what this bug erases — so for query parameters the allowed value set is unavailable both ways.

Suggested fix

Delete createScalarFieldSchema / createFieldSchema from generator.go and have buildPathParameters / buildQueryParameters call the same converter components.schemas uses in types.go. That fixes all three defects at once and removes the class of bug where the two mappers drift apart again.

If a full unification is too large for one change, the minimum viable fix is to call extractValidationConstraints(field, schema) before the return in createScalarFieldSchema, and add an "enum" case — but the duplication would remain, and this is at least the third issue (#161, #216) rooted in the parameter path diverging from the schema path.

A regression test asserting that every field appearing in both parameters and components.schemas has an identical schema in both places would lock this down.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingconsistencyCross-generator consistency issuegen/openapiv3protoc-gen-openapiv3 (OpenAPI spec)

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions