diff --git a/go.mod b/go.mod index e8dd490b8b0..d9d55293ab6 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6 google.golang.org/grpc v1.45.0 google.golang.org/protobuf v1.27.1 + gopkg.in/yaml.v2 v2.4.0 sigs.k8s.io/yaml v1.3.0 ) @@ -21,5 +22,4 @@ require ( golang.org/x/text v0.3.7 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/appengine v1.6.6 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/protoc-gen-openapiv2/defs.bzl b/protoc-gen-openapiv2/defs.bzl index acc703ae509..f8a155c6ff3 100644 --- a/protoc-gen-openapiv2/defs.bzl +++ b/protoc-gen-openapiv2/defs.bzl @@ -63,6 +63,7 @@ def _run_proto_gen_openapi( disable_default_errors, enums_as_ints, omit_enum_default_value, + output_format, simple_operation_ids, proto3_optional_nullable, openapi_configuration, @@ -116,6 +117,9 @@ def _run_proto_gen_openapi( if omit_enum_default_value: args.add("--openapiv2_opt", "omit_enum_default_value=true") + if output_format: + args.add("--openapiv2_opt", "output_format=%s" % output_format) + if proto3_optional_nullable: args.add("--openapiv2_opt", "proto3_optional_nullable=true") @@ -214,6 +218,7 @@ def _proto_gen_openapi_impl(ctx): disable_default_errors = ctx.attr.disable_default_errors, enums_as_ints = ctx.attr.enums_as_ints, omit_enum_default_value = ctx.attr.omit_enum_default_value, + output_format = ctx.attr.output_format, simple_operation_ids = ctx.attr.simple_operation_ids, proto3_optional_nullable = ctx.attr.proto3_optional_nullable, openapi_configuration = ctx.file.openapi_configuration, @@ -301,6 +306,12 @@ protoc_gen_openapiv2 = rule( mandatory = False, doc = "if set, omit default enum value", ), + "output_format": attr.string( + default = "json", + mandatory = False, + values = ["json", "yaml"], + doc = "output content format. Allowed values are: `json`, `yaml`", + ), "simple_operation_ids": attr.bool( default = False, mandatory = False, diff --git a/protoc-gen-openapiv2/internal/genopenapi/BUILD.bazel b/protoc-gen-openapiv2/internal/genopenapi/BUILD.bazel index ee267caab1a..44bb282ef08 100644 --- a/protoc-gen-openapiv2/internal/genopenapi/BUILD.bazel +++ b/protoc-gen-openapiv2/internal/genopenapi/BUILD.bazel @@ -6,6 +6,7 @@ go_library( name = "genopenapi", srcs = [ "doc.go", + "format.go", "generator.go", "helpers.go", "helpers_go111_old.go", @@ -23,6 +24,7 @@ go_library( "@com_github_golang_protobuf//descriptor:go_default_library_gen", "@go_googleapis//google/api:annotations_go_proto", "@go_googleapis//google/rpc:status_go_proto", + "@in_gopkg_yaml_v2//:yaml_v2", "@io_bazel_rules_go//proto/wkt:any_go_proto", "@org_golang_google_protobuf//encoding/protojson", "@org_golang_google_protobuf//proto", @@ -37,8 +39,11 @@ go_test( size = "small", srcs = [ "cycle_test.go", + "format_test.go", + "generator_test.go", "naming_test.go", "template_test.go", + "types_test.go", ], embed = [":genopenapi"], deps = [ @@ -49,6 +54,7 @@ go_test( "//runtime", "@com_github_google_go_cmp//cmp", "@go_googleapis//google/api:annotations_go_proto", + "@in_gopkg_yaml_v2//:yaml_v2", "@io_bazel_rules_go//proto/wkt:field_mask_go_proto", "@org_golang_google_protobuf//proto", "@org_golang_google_protobuf//reflect/protodesc", diff --git a/protoc-gen-openapiv2/internal/genopenapi/format.go b/protoc-gen-openapiv2/internal/genopenapi/format.go new file mode 100644 index 00000000000..e957accc933 --- /dev/null +++ b/protoc-gen-openapiv2/internal/genopenapi/format.go @@ -0,0 +1,43 @@ +package genopenapi + +import ( + "encoding/json" + "errors" + "io" + + "gopkg.in/yaml.v2" +) + +type Format string + +const ( + FormatJSON Format = "json" + FormatYAML Format = "yaml" +) + +type ContentEncoder interface { + Encode(v interface{}) (err error) +} + +func (f Format) Validate() error { + switch f { + case FormatJSON, FormatYAML: + return nil + default: + return errors.New("unknown format: " + string(f)) + } +} + +func (f Format) NewEncoder(w io.Writer) (ContentEncoder, error) { + switch f { + case FormatYAML: + return yaml.NewEncoder(w), nil + case FormatJSON: + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + + return enc, nil + default: + return nil, errors.New("unknown format: " + string(f)) + } +} diff --git a/protoc-gen-openapiv2/internal/genopenapi/format_test.go b/protoc-gen-openapiv2/internal/genopenapi/format_test.go new file mode 100644 index 00000000000..9c3682c3d98 --- /dev/null +++ b/protoc-gen-openapiv2/internal/genopenapi/format_test.go @@ -0,0 +1,105 @@ +package genopenapi_test + +import ( + "bytes" + "encoding/json" + "io" + "reflect" + "testing" + + "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/internal/genopenapi" + "gopkg.in/yaml.v2" +) + +func TestFormatValidate(t *testing.T) { + t.Parallel() + + testCases := [...]struct { + Format genopenapi.Format + Valid bool + }{{ + Format: genopenapi.FormatJSON, + Valid: true, + }, { + Format: genopenapi.FormatYAML, + Valid: true, + }, { + Format: genopenapi.Format("unknown"), + Valid: false, + }, { + Format: genopenapi.Format(""), + Valid: false, + }} + + for _, tc := range testCases { + tc := tc + + t.Run(string(tc.Format), func(t *testing.T) { + t.Parallel() + + err := tc.Format.Validate() + switch { + case tc.Valid && err != nil: + t.Fatalf("expect no validation error, got: %s", err) + case !tc.Valid && err == nil: + t.Fatal("expect validation error, got nil") + } + }) + } +} + +func TestFormatEncode(t *testing.T) { + t.Parallel() + + type contentDecoder interface { + Decode(v interface{}) error + } + + testCases := [...]struct { + Format genopenapi.Format + NewDecoder func(r io.Reader) contentDecoder + }{{ + Format: genopenapi.FormatJSON, + NewDecoder: func(r io.Reader) contentDecoder { + return json.NewDecoder(r) + }, + }, { + Format: genopenapi.FormatYAML, + NewDecoder: func(r io.Reader) contentDecoder { + return yaml.NewDecoder(r) + }, + }} + + for _, tc := range testCases { + tc := tc + + t.Run(string(tc.Format), func(t *testing.T) { + t.Parallel() + + expParams := map[string]string{ + "hello": "world", + } + + var buf bytes.Buffer + enc, err := tc.Format.NewEncoder(&buf) + if err != nil { + t.Fatalf("expect no encoder creating error, got: %s", err) + } + + err = enc.Encode(expParams) + if err != nil { + t.Fatalf("expect no encoding error, got: %s", err) + } + + gotParams := make(map[string]string) + err = tc.NewDecoder(&buf).Decode(&gotParams) + if err != nil { + t.Fatalf("expect no decoding error, got: %s", err) + } + + if !reflect.DeepEqual(expParams, gotParams) { + t.Fatalf("expected: %+v, actual: %+v", expParams, gotParams) + } + }) + } +} diff --git a/protoc-gen-openapiv2/internal/genopenapi/generator.go b/protoc-gen-openapiv2/internal/genopenapi/generator.go index 0848b400505..6c56b38c79f 100644 --- a/protoc-gen-openapiv2/internal/genopenapi/generator.go +++ b/protoc-gen-openapiv2/internal/genopenapi/generator.go @@ -28,7 +28,8 @@ var ( ) type generator struct { - reg *descriptor.Registry + reg *descriptor.Registry + format Format } type wrapper struct { @@ -42,8 +43,11 @@ type GeneratorOptions struct { } // New returns a new generator which generates grpc gateway files. -func New(reg *descriptor.Registry) gen.Generator { - return &generator{reg: reg} +func New(reg *descriptor.Registry, format Format) gen.Generator { + return &generator{ + reg: reg, + format: format, + } } // Merge a lot of OpenAPI file (wrapper) to single one OpenAPI file @@ -143,21 +147,25 @@ func extensionMarshalJSON(so interface{}, extensions []extension) ([]byte, error } // encodeOpenAPI converts OpenAPI file obj to pluginpb.CodeGeneratorResponse_File -func encodeOpenAPI(file *wrapper) (*descriptor.ResponseFile, error) { - var formatted bytes.Buffer - enc := json.NewEncoder(&formatted) - enc.SetIndent("", " ") +func encodeOpenAPI(file *wrapper, format Format) (*descriptor.ResponseFile, error) { + var contentBuf bytes.Buffer + enc, err := format.NewEncoder(&contentBuf) + if err != nil { + return nil, err + } + if err := enc.Encode(*file.swagger); err != nil { return nil, err } + name := file.fileName ext := filepath.Ext(name) base := strings.TrimSuffix(name, ext) - output := fmt.Sprintf("%s.swagger.json", base) + output := fmt.Sprintf("%s.swagger."+string(format), base) return &descriptor.ResponseFile{ CodeGeneratorResponse_File: &pluginpb.CodeGeneratorResponse_File{ Name: proto.String(output), - Content: proto.String(formatted.String()), + Content: proto.String(contentBuf.String()), }, }, nil } @@ -207,7 +215,7 @@ func (g *generator) Generate(targets []*descriptor.File) ([]*descriptor.Response if g.reg.IsAllowMerge() { targetOpenAPI := mergeTargetFile(openapis, g.reg.GetMergeFileName()) - f, err := encodeOpenAPI(targetOpenAPI) + f, err := encodeOpenAPI(targetOpenAPI, g.format) if err != nil { return nil, fmt.Errorf("failed to encode OpenAPI for %s: %s", g.reg.GetMergeFileName(), err) } @@ -215,7 +223,7 @@ func (g *generator) Generate(targets []*descriptor.File) ([]*descriptor.Response glog.V(1).Infof("New OpenAPI file will emit") } else { for _, file := range openapis { - f, err := encodeOpenAPI(file) + f, err := encodeOpenAPI(file, g.format) if err != nil { return nil, fmt.Errorf("failed to encode OpenAPI for %s: %s", file.fileName, err) } diff --git a/protoc-gen-openapiv2/internal/genopenapi/generator_test.go b/protoc-gen-openapiv2/internal/genopenapi/generator_test.go new file mode 100644 index 00000000000..b7ca09dff08 --- /dev/null +++ b/protoc-gen-openapiv2/internal/genopenapi/generator_test.go @@ -0,0 +1,59 @@ +package genopenapi_test + +import ( + "testing" + + "github.com/grpc-ecosystem/grpc-gateway/v2/internal/descriptor" + "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/internal/genopenapi" + "gopkg.in/yaml.v2" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/pluginpb" +) + +func TestGenerate_YAML(t *testing.T) { + t.Parallel() + + reg := descriptor.NewRegistry() + req := &pluginpb.CodeGeneratorRequest{ + ProtoFile: []*descriptorpb.FileDescriptorProto{{ + Name: proto.String("file.proto"), + Package: proto.String("example"), + Options: &descriptorpb.FileOptions{ + GoPackage: proto.String("goexample/v1;goexample"), + }, + }}, + FileToGenerate: []string{ + "file.proto", + }, + } + + if err := reg.Load(req); err != nil { + t.Fatalf("failed to load request: %s", err) + } + + var targets []*descriptor.File + for _, target := range req.FileToGenerate { + f, err := reg.LookupFile(target) + if err != nil { + t.Fatalf("failed to lookup file: %s", err) + } + targets = append(targets, f) + } + + g := genopenapi.New(reg, genopenapi.FormatYAML) + resp, err := g.Generate(targets) + switch { + case err != nil: + t.Fatalf("failed to generate targets: %s", err) + case len(resp) != 1: + t.Fatalf("invalid count, expected: 1, actual: %d", len(resp)) + } + + var p map[string]interface{} + err = yaml.Unmarshal([]byte(resp[0].GetContent()), &p) + if err != nil { + t.Fatalf("failed to unmarshall yaml: %s", err) + } +} diff --git a/protoc-gen-openapiv2/internal/genopenapi/template.go b/protoc-gen-openapiv2/internal/genopenapi/template.go index bc18775eee1..3161d8e2fea 100644 --- a/protoc-gen-openapiv2/internal/genopenapi/template.go +++ b/protoc-gen-openapiv2/internal/genopenapi/template.go @@ -1891,7 +1891,7 @@ func processHeaders(inputHdrs map[string]*openapi_options.Header) (openapiHeader if err != nil { return nil, err } - ret.Default = json.RawMessage(v.Default) + ret.Default = RawExample(v.Default) } hdrs[header] = ret } @@ -2396,7 +2396,7 @@ func updateswaggerObjectFromJSONSchema(s *openapiSchemaObject, j *openapi_option s.Type = strings.ToLower(overrideType[0].String()) } if j != nil && j.GetExample() != "" { - s.Example = json.RawMessage(j.GetExample()) + s.Example = RawExample(j.GetExample()) } if j != nil && j.GetFormat() != "" { s.Format = j.GetFormat() @@ -2438,7 +2438,7 @@ func openapiSchemaFromProtoSchema(s *openapi_options.Schema, reg *descriptor.Reg updateswaggerObjectFromJSONSchema(&ret, s.GetJsonSchema(), reg, data) if s != nil && s.Example != "" { - ret.Example = json.RawMessage(s.Example) + ret.Example = RawExample(s.Example) } return ret diff --git a/protoc-gen-openapiv2/internal/genopenapi/template_test.go b/protoc-gen-openapiv2/internal/genopenapi/template_test.go index 6707fbc59d8..e806524cf41 100644 --- a/protoc-gen-openapiv2/internal/genopenapi/template_test.go +++ b/protoc-gen-openapiv2/internal/genopenapi/template_test.go @@ -1807,19 +1807,19 @@ func TestApplyTemplateHeaders(t *testing.T) { "Boolean": openapiHeaderObject{ Description: "boolean header description", Type: "boolean", - Default: json.RawMessage("true"), + Default: RawExample("true"), Pattern: "^true|false$", }, "Integer": openapiHeaderObject{ Description: "integer header description", Type: "integer", - Default: json.RawMessage("0"), + Default: RawExample("0"), Pattern: "^[0-9]$", }, "Number": openapiHeaderObject{ Description: "number header description", Type: "number", - Default: json.RawMessage("1.2"), + Default: RawExample("1.2"), Pattern: "^[-+]?[0-9]*\\.?[0-9]+([eE][-+]?[0-9]+)?$", }, }, @@ -4193,7 +4193,7 @@ func TestRenderMessagesAsDefinition(t *testing.T) { defs: map[string]openapiSchemaObject{ "Message": {schemaCore: schemaCore{ Type: "object", - Example: json.RawMessage(`{"foo":"bar"}`), + Example: RawExample(`{"foo":"bar"}`), }}, }, }, @@ -4210,7 +4210,7 @@ func TestRenderMessagesAsDefinition(t *testing.T) { defs: map[string]openapiSchemaObject{ "Message": {schemaCore: schemaCore{ Type: "object", - Example: json.RawMessage(`XXXX anything goes XXXX`), + Example: RawExample(`XXXX anything goes XXXX`), }}, }, }, diff --git a/protoc-gen-openapiv2/internal/genopenapi/types.go b/protoc-gen-openapiv2/internal/genopenapi/types.go index 2769053e615..8af16d5c1da 100644 --- a/protoc-gen-openapiv2/internal/genopenapi/types.go +++ b/protoc-gen-openapiv2/internal/genopenapi/types.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/grpc-ecosystem/grpc-gateway/v2/internal/descriptor" + "gopkg.in/yaml.v2" ) type param struct { @@ -15,65 +16,65 @@ type param struct { // http://swagger.io/specification/#infoObject type openapiInfoObject struct { - Title string `json:"title"` - Description string `json:"description,omitempty"` - TermsOfService string `json:"termsOfService,omitempty"` - Version string `json:"version"` + Title string `json:"title" yaml:"title"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + TermsOfService string `json:"termsOfService,omitempty" yaml:"termsOfService,omitempty"` + Version string `json:"version" yaml:"version"` - Contact *openapiContactObject `json:"contact,omitempty"` - License *openapiLicenseObject `json:"license,omitempty"` + Contact *openapiContactObject `json:"contact,omitempty" yaml:"contact,omitempty"` + License *openapiLicenseObject `json:"license,omitempty" yaml:"license,omitempty"` - extensions []extension + extensions []extension `json:"-" yaml:"-"` } // https://swagger.io/specification/#tagObject type openapiTagObject struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - ExternalDocs *openapiExternalDocumentationObject `json:"externalDocs,omitempty"` + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + ExternalDocs *openapiExternalDocumentationObject `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` } // http://swagger.io/specification/#contactObject type openapiContactObject struct { - Name string `json:"name,omitempty"` - URL string `json:"url,omitempty"` - Email string `json:"email,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + URL string `json:"url,omitempty" yaml:"url,omitempty"` + Email string `json:"email,omitempty" yaml:"email,omitempty"` } // http://swagger.io/specification/#licenseObject type openapiLicenseObject struct { - Name string `json:"name,omitempty"` - URL string `json:"url,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + URL string `json:"url,omitempty" yaml:"url,omitempty"` } // http://swagger.io/specification/#externalDocumentationObject type openapiExternalDocumentationObject struct { - Description string `json:"description,omitempty"` - URL string `json:"url,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + URL string `json:"url,omitempty" yaml:"url,omitempty"` } type extension struct { - key string - value json.RawMessage + key string `json:"-" yaml:"-"` + value json.RawMessage `json:"-" yaml:"-"` } // http://swagger.io/specification/#swaggerObject type openapiSwaggerObject struct { - Swagger string `json:"swagger"` - Info openapiInfoObject `json:"info"` - Tags []openapiTagObject `json:"tags,omitempty"` - Host string `json:"host,omitempty"` - BasePath string `json:"basePath,omitempty"` - Schemes []string `json:"schemes,omitempty"` - Consumes []string `json:"consumes"` - Produces []string `json:"produces"` - Paths openapiPathsObject `json:"paths"` - Definitions openapiDefinitionsObject `json:"definitions"` - SecurityDefinitions openapiSecurityDefinitionsObject `json:"securityDefinitions,omitempty"` - Security []openapiSecurityRequirementObject `json:"security,omitempty"` - ExternalDocs *openapiExternalDocumentationObject `json:"externalDocs,omitempty"` - - extensions []extension + Swagger string `json:"swagger" yaml:"swagger"` + Info openapiInfoObject `json:"info" yaml:"info"` + Tags []openapiTagObject `json:"tags,omitempty" yaml:"tags,omitempty"` + Host string `json:"host,omitempty" yaml:"host,omitempty"` + BasePath string `json:"basePath,omitempty" yaml:"basePath,omitempty"` + Schemes []string `json:"schemes,omitempty" yaml:"schemes,omitempty"` + Consumes []string `json:"consumes" yaml:"consumes"` + Produces []string `json:"produces" yaml:"produces"` + Paths openapiPathsObject `json:"paths" yaml:"paths"` + Definitions openapiDefinitionsObject `json:"definitions" yaml:"definitions"` + SecurityDefinitions openapiSecurityDefinitionsObject `json:"securityDefinitions,omitempty" yaml:"securityDefinitions,omitempty"` + Security []openapiSecurityRequirementObject `json:"security,omitempty" yaml:"security,omitempty"` + ExternalDocs *openapiExternalDocumentationObject `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + + extensions []extension `json:"-" yaml:"-"` } // http://swagger.io/specification/#securityDefinitionsObject @@ -81,16 +82,16 @@ type openapiSecurityDefinitionsObject map[string]openapiSecuritySchemeObject // http://swagger.io/specification/#securitySchemeObject type openapiSecuritySchemeObject struct { - Type string `json:"type"` - Description string `json:"description,omitempty"` - Name string `json:"name,omitempty"` - In string `json:"in,omitempty"` - Flow string `json:"flow,omitempty"` - AuthorizationURL string `json:"authorizationUrl,omitempty"` - TokenURL string `json:"tokenUrl,omitempty"` - Scopes openapiScopesObject `json:"scopes,omitempty"` - - extensions []extension + Type string `json:"type" yaml:"type"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + In string `json:"in,omitempty" yaml:"in,omitempty"` + Flow string `json:"flow,omitempty" yaml:"flow,omitempty"` + AuthorizationURL string `json:"authorizationUrl,omitempty" yaml:"authorizationUrl,omitempty"` + TokenURL string `json:"tokenUrl,omitempty" yaml:"tokenUrl,omitempty"` + Scopes openapiScopesObject `json:"scopes,omitempty" yaml:"scopes,omitempty"` + + extensions []extension `json:"-" yaml:"-"` } // http://swagger.io/specification/#scopesObject @@ -104,50 +105,50 @@ type openapiPathsObject map[string]openapiPathItemObject // http://swagger.io/specification/#pathItemObject type openapiPathItemObject struct { - Get *openapiOperationObject `json:"get,omitempty"` - Delete *openapiOperationObject `json:"delete,omitempty"` - Post *openapiOperationObject `json:"post,omitempty"` - Put *openapiOperationObject `json:"put,omitempty"` - Patch *openapiOperationObject `json:"patch,omitempty"` + Get *openapiOperationObject `json:"get,omitempty" yaml:"get,omitempty"` + Delete *openapiOperationObject `json:"delete,omitempty" yaml:"delete,omitempty"` + Post *openapiOperationObject `json:"post,omitempty" yaml:"post,omitempty"` + Put *openapiOperationObject `json:"put,omitempty" yaml:"put,omitempty"` + Patch *openapiOperationObject `json:"patch,omitempty" yaml:"patch,omitempty"` } // http://swagger.io/specification/#operationObject type openapiOperationObject struct { - Summary string `json:"summary,omitempty"` - Description string `json:"description,omitempty"` - OperationID string `json:"operationId"` - Responses openapiResponsesObject `json:"responses"` - Parameters openapiParametersObject `json:"parameters,omitempty"` - Tags []string `json:"tags,omitempty"` - Deprecated bool `json:"deprecated,omitempty"` - Produces []string `json:"produces,omitempty"` - - Security *[]openapiSecurityRequirementObject `json:"security,omitempty"` - ExternalDocs *openapiExternalDocumentationObject `json:"externalDocs,omitempty"` - - extensions []extension + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + OperationID string `json:"operationId" yaml:"operationId"` + Responses openapiResponsesObject `json:"responses" yaml:"responses"` + Parameters openapiParametersObject `json:"parameters,omitempty" yaml:"parameters,omitempty"` + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` + Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` + Produces []string `json:"produces,omitempty" yaml:"produces,omitempty"` + + Security *[]openapiSecurityRequirementObject `json:"security,omitempty" yaml:"security,omitempty"` + ExternalDocs *openapiExternalDocumentationObject `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + + extensions []extension `json:"-" yaml:"-"` } type openapiParametersObject []openapiParameterObject // http://swagger.io/specification/#parameterObject type openapiParameterObject struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - In string `json:"in,omitempty"` - Required bool `json:"required"` - Type string `json:"type,omitempty"` - Format string `json:"format,omitempty"` - Items *openapiItemsObject `json:"items,omitempty"` - Enum []string `json:"enum,omitempty"` - CollectionFormat string `json:"collectionFormat,omitempty"` - Default string `json:"default,omitempty"` - MinItems *int `json:"minItems,omitempty"` - Pattern string `json:"pattern,omitempty"` + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + In string `json:"in,omitempty" yaml:"in,omitempty"` + Required bool `json:"required" yaml:"required"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Format string `json:"format,omitempty" yaml:"format,omitempty"` + Items *openapiItemsObject `json:"items,omitempty" yaml:"items,omitempty"` + Enum []string `json:"enum,omitempty" yaml:"enum,omitempty"` + CollectionFormat string `json:"collectionFormat,omitempty" yaml:"collectionFormat,omitempty"` + Default string `json:"default,omitempty" yaml:"default,omitempty"` + MinItems *int `json:"minItems,omitempty" yaml:"minItems,omitempty"` + Pattern string `json:"pattern,omitempty" yaml:"pattern,omitempty"` // Or you can explicitly refer to another type. If this is defined all // other fields should be empty - Schema *openapiSchemaObject `json:"schema,omitempty"` + Schema *openapiSchemaObject `json:"schema,omitempty" yaml:"schema,omitempty"` } // core part of schema, which is common to itemsObject and schemaObject. @@ -158,19 +159,53 @@ type openapiParameterObject struct { // supported by generation tools such as swagger-codegen and go-swagger. // For protoc-gen-openapiv3, we'd want to add `nullable` instead. type schemaCore struct { - Type string `json:"type,omitempty"` - Format string `json:"format,omitempty"` - Ref string `json:"$ref,omitempty"` - XNullable bool `json:"x-nullable,omitempty"` - Example json.RawMessage `json:"example,omitempty"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Format string `json:"format,omitempty" yaml:"format,omitempty"` + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + XNullable bool `json:"x-nullable,omitempty" yaml:"x-nullable,omitempty"` + Example RawExample `json:"example,omitempty" yaml:"example,omitempty"` - Items *openapiItemsObject `json:"items,omitempty"` + Items *openapiItemsObject `json:"items,omitempty" yaml:"items,omitempty"` // If the item is an enumeration include a list of all the *NAMES* of the // enum values. I'm not sure how well this will work but assuming all enums // start from 0 index it will be great. I don't think that is a good assumption. - Enum []string `json:"enum,omitempty"` - Default string `json:"default,omitempty"` + Enum []string `json:"enum,omitempty" yaml:"enum,omitempty"` + Default string `json:"default,omitempty" yaml:"default,omitempty"` +} + +type RawExample json.RawMessage + +func (m RawExample) MarshalJSON() ([]byte, error) { + return (json.RawMessage)(m).MarshalJSON() +} + +func (m *RawExample) UnmarshalJSON(data []byte) error { + return (*json.RawMessage)(m).UnmarshalJSON(data) +} + +// MarshalYAML implements yaml.Marshaler interface. +// +// It converts RawExample to one of yaml-supported types and returns it. +// +// From yaml.Marshaler docs: The Marshaler interface may be implemented +// by types to customize their behavior when being marshaled into a YAML +// document. The returned value is marshaled in place of the original +// value implementing Marshaler. +func (e RawExample) MarshalYAML() (interface{}, error) { + // From docs, json.Unmarshal will store one of next types to data: + // - bool, for JSON booleans; + // - float64, for JSON numbers; + // - string, for JSON strings; + // - []interface{}, for JSON arrays; + // - map[string]interface{}, for JSON objects; + // - nil for JSON null. + var data interface{} + if err := json.Unmarshal(e, &data); err != nil { + return nil, err + } + + return data, nil } func (s *schemaCore) setRefFromFQN(ref string, reg *descriptor.Registry) error { @@ -189,23 +224,23 @@ type openapiResponsesObject map[string]openapiResponseObject // http://swagger.io/specification/#responseObject type openapiResponseObject struct { - Description string `json:"description"` - Schema openapiSchemaObject `json:"schema"` - Examples map[string]interface{} `json:"examples,omitempty"` - Headers openapiHeadersObject `json:"headers,omitempty"` + Description string `json:"description" yaml:"description"` + Schema openapiSchemaObject `json:"schema" yaml:"schema"` + Examples map[string]interface{} `json:"examples,omitempty" yaml:"examples,omitempty"` + Headers openapiHeadersObject `json:"headers,omitempty" yaml:"headers,omitempty"` - extensions []extension + extensions []extension `json:"-" yaml:"-"` } type openapiHeadersObject map[string]openapiHeaderObject // http://swagger.io/specification/#headerObject type openapiHeaderObject struct { - Description string `json:"description,omitempty"` - Type string `json:"type,omitempty"` - Format string `json:"format,omitempty"` - Default json.RawMessage `json:"default,omitempty"` - Pattern string `json:"pattern,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Format string `json:"format,omitempty" yaml:"format,omitempty"` + Default RawExample `json:"default,omitempty" yaml:"default,omitempty"` + Pattern string `json:"pattern,omitempty" yaml:"pattern,omitempty"` } type keyVal struct { @@ -215,6 +250,19 @@ type keyVal struct { type openapiSchemaObjectProperties []keyVal +func (p openapiSchemaObjectProperties) MarshalYAML() (interface{}, error) { + ms := make(yaml.MapSlice, len(p)) + + for i, v := range p { + ms[i] = yaml.MapItem{ + Key: v.Key, + Value: v.Value, + } + } + + return ms, nil +} + func (op openapiSchemaObjectProperties) MarshalJSON() ([]byte, error) { var buf bytes.Buffer buf.WriteString("{") @@ -241,31 +289,31 @@ func (op openapiSchemaObjectProperties) MarshalJSON() ([]byte, error) { // http://swagger.io/specification/#schemaObject type openapiSchemaObject struct { - schemaCore + schemaCore `yaml:",inline"` // Properties can be recursively defined - Properties *openapiSchemaObjectProperties `json:"properties,omitempty"` - AdditionalProperties *openapiSchemaObject `json:"additionalProperties,omitempty"` - - Description string `json:"description,omitempty"` - Title string `json:"title,omitempty"` - - ExternalDocs *openapiExternalDocumentationObject `json:"externalDocs,omitempty"` - - ReadOnly bool `json:"readOnly,omitempty"` - MultipleOf float64 `json:"multipleOf,omitempty"` - Maximum float64 `json:"maximum,omitempty"` - ExclusiveMaximum bool `json:"exclusiveMaximum,omitempty"` - Minimum float64 `json:"minimum,omitempty"` - ExclusiveMinimum bool `json:"exclusiveMinimum,omitempty"` - MaxLength uint64 `json:"maxLength,omitempty"` - MinLength uint64 `json:"minLength,omitempty"` - Pattern string `json:"pattern,omitempty"` - MaxItems uint64 `json:"maxItems,omitempty"` - MinItems uint64 `json:"minItems,omitempty"` - UniqueItems bool `json:"uniqueItems,omitempty"` - MaxProperties uint64 `json:"maxProperties,omitempty"` - MinProperties uint64 `json:"minProperties,omitempty"` - Required []string `json:"required,omitempty"` + Properties *openapiSchemaObjectProperties `json:"properties,omitempty" yaml:"properties,omitempty"` + AdditionalProperties *openapiSchemaObject `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"` + + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + + ExternalDocs *openapiExternalDocumentationObject `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + + ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` + MultipleOf float64 `json:"multipleOf,omitempty" yaml:"multipleOf,omitempty"` + Maximum float64 `json:"maximum,omitempty" yaml:"maximum,omitempty"` + ExclusiveMaximum bool `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"` + Minimum float64 `json:"minimum,omitempty" yaml:"minimum,omitempty"` + ExclusiveMinimum bool `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"` + MaxLength uint64 `json:"maxLength,omitempty" yaml:"maxLength,omitempty"` + MinLength uint64 `json:"minLength,omitempty" yaml:"minLength,omitempty"` + Pattern string `json:"pattern,omitempty" yaml:"pattern,omitempty"` + MaxItems uint64 `json:"maxItems,omitempty" yaml:"maxItems,omitempty"` + MinItems uint64 `json:"minItems,omitempty" yaml:"minItems,omitempty"` + UniqueItems bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty"` + MaxProperties uint64 `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"` + MinProperties uint64 `json:"minProperties,omitempty" yaml:"minProperties,omitempty"` + Required []string `json:"required,omitempty" yaml:"required,omitempty"` } // http://swagger.io/specification/#definitionsObject diff --git a/protoc-gen-openapiv2/internal/genopenapi/types_test.go b/protoc-gen-openapiv2/internal/genopenapi/types_test.go new file mode 100644 index 00000000000..c6488fb8a78 --- /dev/null +++ b/protoc-gen-openapiv2/internal/genopenapi/types_test.go @@ -0,0 +1,112 @@ +package genopenapi + +import ( + "encoding/json" + "strings" + "testing" + + "gopkg.in/yaml.v2" +) + +func newSpaceReplacer() *strings.Replacer { + return strings.NewReplacer(" ", "", "\n", "", "\t", "") +} + +func TestRawExample(t *testing.T) { + t.Parallel() + + testCases := [...]struct { + In RawExample + Exp string + }{{ + In: RawExample(`1`), + Exp: `1`, + }, { + In: RawExample(`"1"`), + Exp: `"1"`, + }, { + In: RawExample(`{"hello":"worldr"}`), + Exp: ` + hello: + worldr + `, + }} + + sr := newSpaceReplacer() + + for _, tc := range testCases { + tc := tc + + t.Run(string(tc.In), func(t *testing.T) { + t.Parallel() + + ex := RawExample(tc.In) + + out, err := yaml.Marshal(ex) + switch { + case err != nil: + t.Fatalf("expect no yaml marshal error, got: %s", err) + case !json.Valid(tc.In): + t.Fatalf("json is invalid: %#q", tc.In) + case sr.Replace(tc.Exp) != sr.Replace(string(out)): + t.Fatalf("expected: %s, actual: %s", tc.Exp, out) + } + + out, err = json.Marshal(tc.In) + switch { + case err != nil: + t.Fatalf("expect no json marshal error, got: %s", err) + case sr.Replace(string(tc.In)) != sr.Replace(string(out)): + t.Fatalf("expected: %s, actual: %s", tc.In, out) + } + }) + } +} + +func TestOpenapiSchemaObjectProperties(t *testing.T) { + t.Parallel() + + v := map[string]interface{}{ + "example": openapiSchemaObjectProperties{{ + Key: "test1", + Value: 1, + }, { + Key: "test2", + Value: 2, + }}, + } + + t.Run("yaml", func(t *testing.T) { + t.Parallel() + + const exp = ` + example: + test1: 1 + test2: 2 + ` + + sr := newSpaceReplacer() + + out, err := yaml.Marshal(v) + switch { + case err != nil: + t.Fatalf("expect no marshal error, got: %s", err) + case sr.Replace(exp) != sr.Replace(string(out)): + t.Fatalf("expected: %s, actual: %s", exp, out) + } + }) + + t.Run("json", func(t *testing.T) { + t.Parallel() + + const exp = `{"example":{"test1":1,"test2":2}}` + + got, err := json.Marshal(v) + switch { + case err != nil: + t.Fatalf("expect no marshal error, got: %s", err) + case exp != string(got): + t.Fatalf("expected: %s, actual: %s", exp, got) + } + }) +} diff --git a/protoc-gen-openapiv2/main.go b/protoc-gen-openapiv2/main.go index 6fb450aa8e9..8728cab2d9e 100644 --- a/protoc-gen-openapiv2/main.go +++ b/protoc-gen-openapiv2/main.go @@ -37,6 +37,7 @@ var ( generateUnboundMethods = flag.Bool("generate_unbound_methods", false, "generate swagger metadata even for RPC methods that have no HttpRule annotation") recursiveDepth = flag.Int("recursive-depth", 1000, "maximum recursion count allowed for a field type") omitEnumDefaultValue = flag.Bool("omit_enum_default_value", false, "if set, omit default enum value") + outputFormat = flag.String("output_format", string(genopenapi.FormatJSON), fmt.Sprintf("output content format. Allowed values are: `%s`, `%s`", genopenapi.FormatJSON, genopenapi.FormatYAML)) ) // Variables set by goreleaser at build time @@ -129,7 +130,13 @@ func main() { } } - g := genopenapi.New(reg) + format := genopenapi.Format(*outputFormat) + if err := format.Validate(); err != nil { + emitError(err) + return + } + + g := genopenapi.New(reg, format) if err := genopenapi.AddErrorDefs(reg); err != nil { emitError(err)