diff --git a/cmd/protoc-gen-openapi/examples/tests/openapiv3enum/README.md b/cmd/protoc-gen-openapi/examples/tests/openapiv3enum/README.md new file mode 100644 index 00000000..b0c8201e --- /dev/null +++ b/cmd/protoc-gen-openapi/examples/tests/openapiv3enum/README.md @@ -0,0 +1,74 @@ +# OpenAPI Enumeration Feature Improvements + +## Overview + +We have improved the enumeration support in the `protoc-gen-openapi` tool to correctly generate OpenAPI documentation containing enumeration values, with support for handling naming conflicts in nested enumerations. + +## Major Improvements + +1. **Unified Enumeration Value Generation**: Enumeration values are generated regardless of what `enum_type` is set to +2. **Field Name Usage**: Enumeration values use the field names defined in proto, rather than numeric values +3. **Type Unification**: Changed the default type from "integer" to "string" since we're using enumeration field names +4. **Backward Compatibility**: Still supports the `enum_type` parameter to control behavior +5. **Nested Enumeration Support**: Correctly handles enumerations nested within messages, avoiding naming conflicts +6. **Naming Conflict Resolution**: Nested enumerations use the `ParentMessage_EnumName` format to avoid conflicts + +## Nested Enumeration Handling + +### Naming Rules + +- **File-level enumerations**: `EnumName` (e.g., `UserStatus`) +- **Nested enumerations**: `ParentMessage_EnumName` (e.g., `User_Status`) + +### Example + +```protobuf +message User { + enum Status { // Nested enumeration + UNKNOWN = 0; + ACTIVE = 1; + INACTIVE = 2; + } + Status status = 1; +} + +enum UserRole { // File-level enumeration + ADMIN = 0; + USER = 1; +} +``` + +Generated OpenAPI Schema: + +```yaml +components: + schemas: + User_Status: # Nested enumeration, using concatenated name + type: string + format: enum + enum: + - UNKNOWN + - ACTIVE + - INACTIVE + + UserRole: # File-level enumeration, using original name + type: string + format: enum + enum: + - ADMIN + - USER + + User: + type: object + properties: + status: + $ref: '#/components/schemas/User_Status' # Reference to nested enumeration +``` + +## Important Notes + +1. Enumeration values use the field names defined in proto, not numeric values +2. This ensures the generated OpenAPI documentation is clearer and more readable +3. Maintains compatibility with existing code +4. Nested enumerations automatically handle naming conflicts using the `ParentMessage_EnumName` format +5. All enumeration types are defined in `components/schemas`, with fields referencing them via `$ref` diff --git a/cmd/protoc-gen-openapi/examples/tests/openapiv3enum/message.proto b/cmd/protoc-gen-openapi/examples/tests/openapiv3enum/message.proto new file mode 100644 index 00000000..7c4931e9 --- /dev/null +++ b/cmd/protoc-gen-openapi/examples/tests/openapiv3enum/message.proto @@ -0,0 +1,109 @@ +// Copyright 2024 Google LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +syntax = "proto3"; + +package tests.openapiv3enum.message.v1; + +import "google/api/annotations.proto"; + +option go_package = "github.com/google/gnostic/apps/protoc-gen-openapi/examples/tests/openapiv3enum/message/v1;message"; + +// User management service +service UserService { + rpc CreateUser(User) returns (User) { + option (google.api.http) = { + post : "/v1/users" + body : "*" + }; + } + + rpc GetUser(GetUserRequest) returns (User) { + option (google.api.http) = { + get : "/v1/users/{user_id}" + }; + } + + rpc UpdateUserStatus(UpdateUserStatusRequest) returns (User) { + option (google.api.http) = { + patch : "/v1/users/{user_id}/status" + body : "*" + }; + } +} + +// File-level enumeration for user roles +enum UserRole { + USER_ROLE_UNSPECIFIED = 0; + USER_ROLE_ADMIN = 1; + USER_ROLE_USER = 2; + USER_ROLE_MODERATOR = 3; +} + +// File-level enumeration for account types +enum AccountType { + ACCOUNT_TYPE_UNSPECIFIED = 0; + ACCOUNT_TYPE_PERSONAL = 1; + ACCOUNT_TYPE_BUSINESS = 2; + ACCOUNT_TYPE_ENTERPRISE = 3; +} + +message User { + string user_id = 1; + string name = 2; + string email = 3; + + // Nested enumeration for user status + enum Status { + STATUS_UNSPECIFIED = 0; + STATUS_ACTIVE = 1; + STATUS_INACTIVE = 2; + STATUS_SUSPENDED = 3; + STATUS_DELETED = 4; + } + + // Nested enumeration for verification status + enum VerificationStatus { + VERIFICATION_UNSPECIFIED = 0; + VERIFICATION_PENDING = 1; + VERIFICATION_VERIFIED = 2; + VERIFICATION_FAILED = 3; + } + + Status status = 4; + VerificationStatus verification_status = 5; + UserRole role = 6; + AccountType account_type = 7; + + // Nested enumeration for notification preferences + enum NotificationPreference { + NOTIFICATION_UNSPECIFIED = 0; + NOTIFICATION_EMAIL = 1; + NOTIFICATION_SMS = 2; + NOTIFICATION_PUSH = 3; + NOTIFICATION_NONE = 4; + } + + NotificationPreference notification_preference = 8; +} + +message GetUserRequest { + string user_id = 1; +} + +message UpdateUserStatusRequest { + string user_id = 1; + User.Status status = 2; +} diff --git a/cmd/protoc-gen-openapi/examples/tests/openapiv3enum/openapi.yaml b/cmd/protoc-gen-openapi/examples/tests/openapiv3enum/openapi.yaml new file mode 100644 index 00000000..d78c0760 --- /dev/null +++ b/cmd/protoc-gen-openapi/examples/tests/openapiv3enum/openapi.yaml @@ -0,0 +1,333 @@ +# Generated with protoc-gen-openapi +# https://github.com/google/gnostic/tree/master/cmd/protoc-gen-openapi + +openapi: 3.0.3 +info: + title: UserService API + description: User management service + version: 0.0.1 +paths: + /v1/users: + post: + tags: + - UserService + operationId: UserService_CreateUser + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + default: + description: Default error response + content: + application/json: + schema: + $ref: '#/components/schemas/Status' + /v1/users/{userId}: + get: + tags: + - UserService + operationId: UserService_GetUser + parameters: + - name: userId + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + default: + description: Default error response + content: + application/json: + schema: + $ref: '#/components/schemas/Status' + /v1/users/{userId}/status: + patch: + tags: + - UserService + operationId: UserService_UpdateUserStatus + parameters: + - name: userId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserStatusRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + default: + description: Default error response + content: + application/json: + schema: + $ref: '#/components/schemas/Status' +components: + schemas: + AccountType: + enum: + - ACCOUNT_TYPE_UNSPECIFIED + - ACCOUNT_TYPE_PERSONAL + - ACCOUNT_TYPE_BUSINESS + - ACCOUNT_TYPE_ENTERPRISE + type: string + format: enum + Edition: + enum: + - EDITION_UNKNOWN + - EDITION_LEGACY + - EDITION_PROTO2 + - EDITION_PROTO3 + - EDITION_2023 + - EDITION_2024 + - EDITION_1_TEST_ONLY + - EDITION_2_TEST_ONLY + - EDITION_99997_TEST_ONLY + - EDITION_99998_TEST_ONLY + - EDITION_99999_TEST_ONLY + - EDITION_MAX + type: string + format: enum + ExtensionRangeOptions_VerificationState: + enum: + - DECLARATION + - UNVERIFIED + type: string + format: enum + FeatureSet_EnumType: + enum: + - ENUM_TYPE_UNKNOWN + - OPEN + - CLOSED + type: string + format: enum + FeatureSet_FieldPresence: + enum: + - FIELD_PRESENCE_UNKNOWN + - EXPLICIT + - IMPLICIT + - LEGACY_REQUIRED + type: string + format: enum + FeatureSet_JsonFormat: + enum: + - JSON_FORMAT_UNKNOWN + - ALLOW + - LEGACY_BEST_EFFORT + type: string + format: enum + FeatureSet_MessageEncoding: + enum: + - MESSAGE_ENCODING_UNKNOWN + - LENGTH_PREFIXED + - DELIMITED + type: string + format: enum + FeatureSet_RepeatedFieldEncoding: + enum: + - REPEATED_FIELD_ENCODING_UNKNOWN + - PACKED + - EXPANDED + type: string + format: enum + FeatureSet_Utf8Validation: + enum: + - UTF8_VALIDATION_UNKNOWN + - VERIFY + - NONE + type: string + format: enum + FieldDescriptorProto_Label: + enum: + - LABEL_OPTIONAL + - LABEL_REPEATED + - LABEL_REQUIRED + type: string + format: enum + FieldDescriptorProto_Type: + enum: + - TYPE_DOUBLE + - TYPE_FLOAT + - TYPE_INT64 + - TYPE_UINT64 + - TYPE_INT32 + - TYPE_FIXED64 + - TYPE_FIXED32 + - TYPE_BOOL + - TYPE_STRING + - TYPE_GROUP + - TYPE_MESSAGE + - TYPE_BYTES + - TYPE_UINT32 + - TYPE_ENUM + - TYPE_SFIXED32 + - TYPE_SFIXED64 + - TYPE_SINT32 + - TYPE_SINT64 + type: string + format: enum + FieldOptions_CType: + enum: + - STRING + - CORD + - STRING_PIECE + type: string + format: enum + FieldOptions_JSType: + enum: + - JS_NORMAL + - JS_STRING + - JS_NUMBER + type: string + format: enum + FieldOptions_OptionRetention: + enum: + - RETENTION_UNKNOWN + - RETENTION_RUNTIME + - RETENTION_SOURCE + type: string + format: enum + FieldOptions_OptionTargetType: + enum: + - TARGET_TYPE_UNKNOWN + - TARGET_TYPE_FILE + - TARGET_TYPE_EXTENSION_RANGE + - TARGET_TYPE_MESSAGE + - TARGET_TYPE_FIELD + - TARGET_TYPE_ONEOF + - TARGET_TYPE_ENUM + - TARGET_TYPE_ENUM_ENTRY + - TARGET_TYPE_SERVICE + - TARGET_TYPE_METHOD + type: string + format: enum + FileOptions_OptimizeMode: + enum: + - SPEED + - CODE_SIZE + - LITE_RUNTIME + type: string + format: enum + GeneratedCodeInfo_Annotation_Semantic: + enum: + - NONE + - SET + - ALIAS + type: string + format: enum + GoogleProtobufAny: + type: object + properties: + '@type': + type: string + description: The type of the serialized message. + additionalProperties: true + description: Contains an arbitrary serialized message along with a @type that describes the type of the serialized message. + MethodOptions_IdempotencyLevel: + enum: + - IDEMPOTENCY_UNKNOWN + - NO_SIDE_EFFECTS + - IDEMPOTENT + type: string + format: enum + Status: + type: object + properties: + code: + type: integer + description: The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code]. + format: int32 + message: + type: string + description: A developer-facing error message, which should be in English. Any user-facing error message should be localized and sent in the [google.rpc.Status.details][google.rpc.Status.details] field, or localized by the client. + details: + type: array + items: + $ref: '#/components/schemas/GoogleProtobufAny' + description: A list of messages that carry the error details. There is a common set of message types for APIs to use. + description: 'The `Status` type defines a logical error model that is suitable for different programming environments, including REST APIs and RPC APIs. It is used by [gRPC](https://github.com/grpc). Each `Status` message contains three pieces of data: error code, error message, and error details. You can find out more about this error model and how to work with it in the [API Design Guide](https://cloud.google.com/apis/design/errors).' + UpdateUserStatusRequest: + type: object + properties: + userId: + type: string + status: + $ref: '#/components/schemas/User_Status' + User: + type: object + properties: + userId: + type: string + name: + type: string + email: + type: string + status: + $ref: '#/components/schemas/User_Status' + verificationStatus: + $ref: '#/components/schemas/User_VerificationStatus' + role: + $ref: '#/components/schemas/UserRole' + accountType: + $ref: '#/components/schemas/AccountType' + notificationPreference: + $ref: '#/components/schemas/User_NotificationPreference' + UserRole: + enum: + - USER_ROLE_UNSPECIFIED + - USER_ROLE_ADMIN + - USER_ROLE_USER + - USER_ROLE_MODERATOR + type: string + format: enum + User_NotificationPreference: + enum: + - NOTIFICATION_UNSPECIFIED + - NOTIFICATION_EMAIL + - NOTIFICATION_SMS + - NOTIFICATION_PUSH + - NOTIFICATION_NONE + type: string + format: enum + User_Status: + enum: + - STATUS_UNSPECIFIED + - STATUS_ACTIVE + - STATUS_INACTIVE + - STATUS_SUSPENDED + - STATUS_DELETED + type: string + format: enum + User_VerificationStatus: + enum: + - VERIFICATION_UNSPECIFIED + - VERIFICATION_PENDING + - VERIFICATION_VERIFIED + - VERIFICATION_FAILED + type: string + format: enum +tags: + - name: UserService diff --git a/cmd/protoc-gen-openapi/generator/generator.go b/cmd/protoc-gen-openapi/generator/generator.go index e548ab21..433be782 100644 --- a/cmd/protoc-gen-openapi/generator/generator.go +++ b/cmd/protoc-gen-openapi/generator/generator.go @@ -140,6 +140,9 @@ func (g *OpenAPIv3Generator) buildDocumentV3() *v3.Document { g.reflect.requiredSchemas = g.reflect.requiredSchemas[count:len(g.reflect.requiredSchemas)] } + // Add enum schemas to the document + g.addSchemasForEnumsToDocumentV3(d) + // If there is only 1 service, then use it's title for the // document, if the document is missing it. if len(d.Tags) == 1 { @@ -906,3 +909,90 @@ func (g *OpenAPIv3Generator) addSchemasForMessagesToDocumentV3(d *v3.Document, m }) } } + +// addSchemasForEnumsToDocumentV3 adds enum schemas to the document +func (g *OpenAPIv3Generator) addSchemasForEnumsToDocumentV3(d *v3.Document) { + for _, file := range g.plugin.Files { + g.addSchemasForNestedEnumsToDocumentV3(d, file.Messages) + } + + for _, file := range g.plugin.Files { + for _, enum := range file.Enums { + enumName := string(enum.Desc.Name()) + + if !contains(g.generatedSchemas, enumName) { + enumSchema := &v3.NamedSchemaOrReference{ + Name: enumName, + Value: &v3.SchemaOrReference{ + Oneof: &v3.SchemaOrReference_Schema{ + Schema: &v3.Schema{ + Type: "string", + Format: "enum", + Enum: g.generateEnumValues(enum.Desc), + }, + }, + }, + } + g.addSchemaToDocumentV3(d, enumSchema) + g.generatedSchemas = append(g.generatedSchemas, enumName) + } + } + } +} + +func (g *OpenAPIv3Generator) addSchemasForNestedEnumsToDocumentV3(d *v3.Document, messages []*protogen.Message) { + g.addSchemasForNestedEnumsToDocumentV3Recursive(d, messages, "") +} + +func (g *OpenAPIv3Generator) addSchemasForNestedEnumsToDocumentV3Recursive(d *v3.Document, messages []*protogen.Message, parentPath string) { + for _, message := range messages { + // Build the full path for the current message + currentPath := string(message.Desc.Name()) + if parentPath != "" { + currentPath = parentPath + "_" + currentPath + } + + // Process nested enums in the message + for _, enum := range message.Enums { + // Use the same naming logic as in reflector.go + enumName := string(enum.Desc.Name()) + fullEnumName := currentPath + "_" + enumName + + // Check if this enum has already been generated + if !contains(g.generatedSchemas, fullEnumName) { + // Directly create enum schema + enumSchema := &v3.NamedSchemaOrReference{ + Name: fullEnumName, + Value: &v3.SchemaOrReference{ + Oneof: &v3.SchemaOrReference_Schema{ + Schema: &v3.Schema{ + Type: "string", + Format: "enum", + Enum: g.generateEnumValues(enum.Desc), + }, + }, + }, + } + g.addSchemaToDocumentV3(d, enumSchema) + // Mark as generated + g.generatedSchemas = append(g.generatedSchemas, fullEnumName) + } + } + + // Recursively process nested messages, passing the current path + if message.Messages != nil { + g.addSchemasForNestedEnumsToDocumentV3Recursive(d, message.Messages, currentPath) + } + } +} + +func (g *OpenAPIv3Generator) generateEnumValues(enum protoreflect.EnumDescriptor) []*v3.Any { + enumValues := make([]*v3.Any, 0, enum.Values().Len()) + for i := 0; i < enum.Values().Len(); i++ { + enumValue := enum.Values().Get(i) + enumValues = append(enumValues, &v3.Any{ + Yaml: string(enumValue.Name()), + }) + } + return enumValues +} diff --git a/cmd/protoc-gen-openapi/generator/reflector.go b/cmd/protoc-gen-openapi/generator/reflector.go index 31a0f930..9e095dca 100644 --- a/cmd/protoc-gen-openapi/generator/reflector.go +++ b/cmd/protoc-gen-openapi/generator/reflector.go @@ -216,7 +216,25 @@ func (r *OpenAPIv3Reflector) schemaOrReferenceForField(field protoreflect.FieldD kindSchema = wk.NewStringSchema() case protoreflect.EnumKind: - kindSchema = wk.NewEnumSchema(*&r.conf.EnumType, field) + // Collect enum type information for the generator + enumDesc := field.Enum() + enumName := string(enumDesc.Name()) + + // Check if the enum itself is nested (not whether the field is nested) + // If the enum's parent is a message type, it's a nested enum + parent := enumDesc.Parent() + if parent != nil { + if parentMsg, ok := parent.(protoreflect.MessageDescriptor); ok { + // This is a nested enum, need to concatenate the parent message name + parentName := string(parentMsg.Name()) + enumName = parentName + "_" + enumName + } + } + + if !contains(r.requiredSchemas, enumName) { + r.requiredSchemas = append(r.requiredSchemas, enumName) + } + kindSchema = wk.NewEnumSchemaReference(field) case protoreflect.BoolKind: kindSchema = wk.NewBooleanSchema() diff --git a/cmd/protoc-gen-openapi/generator/wellknown/schemas.go b/cmd/protoc-gen-openapi/generator/wellknown/schemas.go index 8840dde8..1cadd902 100644 --- a/cmd/protoc-gen-openapi/generator/wellknown/schemas.go +++ b/cmd/protoc-gen-openapi/generator/wellknown/schemas.go @@ -50,22 +50,31 @@ func NewNumberSchema(format string) *v3.SchemaOrReference { Schema: &v3.Schema{Type: "number", Format: format}}} } -func NewEnumSchema(enum_type *string, field protoreflect.FieldDescriptor) *v3.SchemaOrReference { - schema := &v3.Schema{Format: "enum"} - if enum_type != nil && *enum_type == "string" { - schema.Type = "string" - schema.Enum = make([]*v3.Any, 0, field.Enum().Values().Len()) - for i := 0; i < field.Enum().Values().Len(); i++ { - schema.Enum = append(schema.Enum, &v3.Any{ - Yaml: string(field.Enum().Values().Get(i).Name()), - }) +func NewEnumSchemaReference(field protoreflect.FieldDescriptor) *v3.SchemaOrReference { + enumName := buildFullEnumName(field) + + return &v3.SchemaOrReference{ + Oneof: &v3.SchemaOrReference_Reference{ + Reference: &v3.Reference{ + XRef: "#/components/schemas/" + enumName, + }, + }, + } +} + +func buildFullEnumName(field protoreflect.FieldDescriptor) string { + enumDesc := field.Enum() + enumName := string(enumDesc.Name()) + + parent := enumDesc.Parent() + if parent != nil { + if parentMsg, ok := parent.(protoreflect.MessageDescriptor); ok { + parentName := string(parentMsg.Name()) + enumName = parentName + "_" + enumName } - } else { - schema.Type = "integer" } - return &v3.SchemaOrReference{ - Oneof: &v3.SchemaOrReference_Schema{ - Schema: schema}} + + return enumName } func NewListSchema(item_schema *v3.SchemaOrReference) *v3.SchemaOrReference { @@ -168,9 +177,12 @@ func NewGoogleProtobufStructSchema() *v3.SchemaOrReference { // google.protobuf.Value is handled specially // See here for the details on the JSON mapping: -// https://developers.google.com/protocol-buffers/docs/proto3#json +// +// https://developers.google.com/protocol-buffers/docs/proto3#json +// // and here: -// https://developers.google.com/protocol-buffers/docs/reference/google.protobuf#google.protobuf.Value +// +// https://developers.google.com/protocol-buffers/docs/reference/google.protobuf#google.protobuf.Value func NewGoogleProtobufValueSchema(name string) *v3.NamedSchemaOrReference { return &v3.NamedSchemaOrReference{ Name: name, @@ -186,7 +198,8 @@ func NewGoogleProtobufValueSchema(name string) *v3.NamedSchemaOrReference { // google.protobuf.Any is handled specially // See here for the details on the JSON mapping: -// https://developers.google.com/protocol-buffers/docs/proto3#json +// +// https://developers.google.com/protocol-buffers/docs/proto3#json func NewGoogleProtobufAnySchema(name string) *v3.NamedSchemaOrReference { return &v3.NamedSchemaOrReference{ Name: name,