From b27ea6b8587f6735a309e47e1abcf3acd12f7953 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Sat, 24 Feb 2024 14:48:23 -0500 Subject: cmd: import twirp-openapi-gen, fix some bugs Signed-off-by: Xe Iaso --- cmd/twirp-openapi-gen/LICENSE | 201 +++ cmd/twirp-openapi-gen/README.md | 3 + .../internal/generator/aliases.go | 85 + .../internal/generator/generator.go | 187 +++ .../internal/generator/generator_test.go | 317 ++++ .../internal/generator/handlers.go | 562 +++++++ .../internal/generator/testdata/doc.json | 405 +++++ .../testdata/gen/go/payment/v1alpha1/payment.pb.go | 263 +++ .../generator/testdata/gen/go/pet/v1/pet.pb.go | 896 ++++++++++ .../generator/testdata/gen/go/pet/v1/pet.twirp.go | 1705 ++++++++++++++++++++ .../paymentapis/payment/v1alpha1/payment.proto | 24 + .../internal/generator/testdata/pet-api-doc.json | 321 ++++ .../internal/generator/testdata/pet-api-doc.yaml | 297 ++++ .../generator/testdata/petapis/pet/v1/pet.proto | 115 ++ cmd/twirp-openapi-gen/main.go | 90 ++ 15 files changed, 5471 insertions(+) create mode 100644 cmd/twirp-openapi-gen/LICENSE create mode 100644 cmd/twirp-openapi-gen/README.md create mode 100644 cmd/twirp-openapi-gen/internal/generator/aliases.go create mode 100644 cmd/twirp-openapi-gen/internal/generator/generator.go create mode 100644 cmd/twirp-openapi-gen/internal/generator/generator_test.go create mode 100644 cmd/twirp-openapi-gen/internal/generator/handlers.go create mode 100644 cmd/twirp-openapi-gen/internal/generator/testdata/doc.json create mode 100644 cmd/twirp-openapi-gen/internal/generator/testdata/gen/go/payment/v1alpha1/payment.pb.go create mode 100644 cmd/twirp-openapi-gen/internal/generator/testdata/gen/go/pet/v1/pet.pb.go create mode 100644 cmd/twirp-openapi-gen/internal/generator/testdata/gen/go/pet/v1/pet.twirp.go create mode 100644 cmd/twirp-openapi-gen/internal/generator/testdata/paymentapis/payment/v1alpha1/payment.proto create mode 100644 cmd/twirp-openapi-gen/internal/generator/testdata/pet-api-doc.json create mode 100644 cmd/twirp-openapi-gen/internal/generator/testdata/pet-api-doc.yaml create mode 100644 cmd/twirp-openapi-gen/internal/generator/testdata/petapis/pet/v1/pet.proto create mode 100644 cmd/twirp-openapi-gen/main.go (limited to 'cmd') diff --git a/cmd/twirp-openapi-gen/LICENSE b/cmd/twirp-openapi-gen/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/cmd/twirp-openapi-gen/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/cmd/twirp-openapi-gen/README.md b/cmd/twirp-openapi-gen/README.md new file mode 100644 index 0000000..d3bf9a8 --- /dev/null +++ b/cmd/twirp-openapi-gen/README.md @@ -0,0 +1,3 @@ +# twirp-openapi-gen + +This was imported from [github.com/blockthrough/twirp-openapi-gen](https://github.com/blockthrough/twirp-openapi-gen) and a few non-upstreamable changes were made. These changes are made in order to meet my needs in particular. If you want to use this tool, I recommend you to use the original repository. diff --git a/cmd/twirp-openapi-gen/internal/generator/aliases.go b/cmd/twirp-openapi-gen/internal/generator/aliases.go new file mode 100644 index 0000000..a8e29f3 --- /dev/null +++ b/cmd/twirp-openapi-gen/internal/generator/aliases.go @@ -0,0 +1,85 @@ +package generator + +var typeAliases = map[string]struct { + Type, Format string +}{ + // proto numeric types + "int32": {Type: "integer", Format: "int32"}, + "uint32": {Type: "integer", Format: "uint32"}, + "sint32": {Type: "integer", Format: "int32"}, + "fixed32": {Type: "integer", Format: "int32"}, + "sfixed32": {Type: "integer", Format: "int32"}, + + // proto numeric types, 64bit + "int64": {Type: "string", Format: "int64"}, + "uint64": {Type: "string", Format: "uint64"}, + "sint64": {Type: "string", Format: "int64"}, + "fixed64": {Type: "string", Format: "int64"}, + "sfixed64": {Type: "string", Format: "int64"}, + + "double": {Type: "number", Format: "double"}, + "float": {Type: "number", Format: "float"}, + + // effectively copies google.protobuf.BytesValue + "bytes": { + Type: "string", + Format: "byte", + }, + + // It is what it is + "bool": { + Type: "boolean", + Format: "boolean", + }, + + "google.protobuf.Timestamp": { + Type: "string", + Format: "date-time", + }, + "google.protobuf.Duration": { + Type: "string", + }, + "google.protobuf.StringValue": { + Type: "string", + }, + "google.protobuf.BytesValue": { + Type: "string", + Format: "byte", + }, + "google.protobuf.Int32Value": { + Type: "integer", + Format: "int32", + }, + "google.protobuf.UInt32Value": { + Type: "integer", + Format: "uint32", + }, + "google.protobuf.Int64Value": { + Type: "string", + Format: "int64", + }, + "google.protobuf.UInt64Value": { + Type: "string", + Format: "uint64", + }, + "google.protobuf.FloatValue": { + Type: "number", + Format: "float", + }, + "google.protobuf.DoubleValue": { + Type: "number", + Format: "double", + }, + "google.protobuf.BoolValue": { + Type: "boolean", + Format: "boolean", + }, + "google.protobuf.Empty": { + Type: "object", + }, + + "google.type.DateTime": { + Type: "string", + Format: "date-time", + }, +} diff --git a/cmd/twirp-openapi-gen/internal/generator/generator.go b/cmd/twirp-openapi-gen/internal/generator/generator.go new file mode 100644 index 0000000..2a08055 --- /dev/null +++ b/cmd/twirp-openapi-gen/internal/generator/generator.go @@ -0,0 +1,187 @@ +package generator + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/emicklei/proto" + "github.com/getkin/kin-openapi/openapi3" + "github.com/invopop/yaml" +) + +type generatorConfig struct { + protoPaths []string + servers []string + title string + docVersion string + pathPrefix string + format string +} + +type Option func(config *generatorConfig) error + +func ProtoPaths(paths []string) Option { + return func(config *generatorConfig) error { + config.protoPaths = paths + return nil + } +} + +func Servers(servers []string) Option { + return func(config *generatorConfig) error { + config.servers = servers + return nil + } +} + +func Title(title string) Option { + return func(config *generatorConfig) error { + config.title = title + return nil + } +} + +func DocVersion(version string) Option { + return func(config *generatorConfig) error { + config.docVersion = version + return nil + } +} + +func PathPrefix(pathPrefix string) Option { + return func(config *generatorConfig) error { + config.pathPrefix = pathPrefix + return nil + } +} + +func Format(format string) Option { + return func(config *generatorConfig) error { + config.format = format + return nil + } +} + +type generator struct { + openAPIV3 *openapi3.T + + conf *generatorConfig + inputFiles []string + packageName string + + importedFiles map[string]struct{} +} + +func NewGenerator(inputFiles []string, options ...Option) (*generator, error) { + conf := generatorConfig{} + for _, opt := range options { + if err := opt(&conf); err != nil { + return nil, err + } + } + + if len(inputFiles) < 1 { + return nil, fmt.Errorf("missing input files") + } + + openAPIV3 := openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Title: conf.title, + Version: conf.docVersion, + }, + Paths: openapi3.Paths{}, + Components: &openapi3.Components{ + Schemas: map[string]*openapi3.SchemaRef{}, + }, + } + + for _, server := range conf.servers { + openAPIV3.Servers = append(openAPIV3.Servers, &openapi3.Server{URL: server}) + } + + slog.Debug("generating doc", "format", conf.format, "inputFiles", inputFiles) + + return &generator{ + inputFiles: inputFiles, + openAPIV3: &openAPIV3, + conf: &conf, + importedFiles: map[string]struct{}{}, + }, nil +} + +func (gen *generator) Generate(filename string) error { + if _, err := gen.Parse(); err != nil { + return err + } + + if err := gen.Save(filename); err != nil { + return err + } + + return nil +} + +func (gen *generator) Parse() (*openapi3.T, error) { + for _, filename := range gen.inputFiles { + protoFile, err := readProtoFile(filename, gen.conf.protoPaths) + if err != nil { + return nil, fmt.Errorf("readProtoFile: %w", err) + } + proto.Walk(protoFile, gen.Handlers()...) + } + + slog.Debug("generated", "paths", len(gen.openAPIV3.Paths), "components", len(gen.openAPIV3.Components.Schemas)) + return gen.openAPIV3, nil +} + +func (gen *generator) Save(filename string) error { + var by []byte + var err error + switch gen.conf.format { + case "json": + by, err = gen.JSON() + case "yaml", "yml": + by, err = gen.YAML() + default: + return fmt.Errorf("missing format") + } + if err != nil { + return err + } + + return os.WriteFile(filename, by, os.ModePerm^0111) +} + +func (gen *generator) JSON() ([]byte, error) { + return json.MarshalIndent(gen.openAPIV3, "", " ") +} + +func (gen *generator) YAML() ([]byte, error) { + return yaml.Marshal(gen.openAPIV3) +} + +func readProtoFile(filename string, protoPaths []string) (*proto.Proto, error) { + var file *os.File + var err error + for _, path := range append(protoPaths, "") { + file, err = os.Open(filepath.Join(path, filename)) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("Open: %w", err) + } + break + } + if file == nil { + return nil, fmt.Errorf("could not read file %q", filename) + } + defer file.Close() + + parser := proto.NewParser(file) + return parser.Parse() +} diff --git a/cmd/twirp-openapi-gen/internal/generator/generator_test.go b/cmd/twirp-openapi-gen/internal/generator/generator_test.go new file mode 100644 index 0000000..78af950 --- /dev/null +++ b/cmd/twirp-openapi-gen/internal/generator/generator_test.go @@ -0,0 +1,317 @@ +package generator + +import ( + "flag" + "log/slog" + "os" + "strings" + "testing" +) + +type ProtoRPC struct { + name string + input string + output string + desc string +} + +type ProtoMessage struct { + name string + fields []ProtoField +} + +type ProtoField struct { + name string + fieldType string + format string + desc string + enums []string + ref string + itemsRef string + itemsType string +} + +var ( + verbose = flag.Bool("slog.verbose", false, "print debug logs to the console") +) + +func init() { + if *verbose { + h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelDebug, + }) + slog.SetDefault(slog.New(h)) + } else { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + }))) + } +} + +func TestGenerator(t *testing.T) { + flag.Parse() + + opts := []Option{ + ProtoPaths([]string{"./testdata/paymentapis", "./testdata/petapis"}), + Servers([]string{"https://example.com"}), + Title("Test"), + DocVersion("0.1"), + Format("json"), + } + gen, err := NewGenerator([]string{"./testdata/petapis/pet/v1/pet.proto"}, opts...) + if err != nil { + t.Fatal(err) + } + openAPI, err := gen.Parse() + if err != nil { + t.Fatal(err) + } + + if err := gen.Save("./testdata/doc.json"); err != nil { + t.Fatal(err) + } + + pkgName := "pet.v1" + serviceName := "PetStoreService" + rpcs := []ProtoRPC{ + { + name: "GetPet", + input: "GetPetRequest", + output: "GetPetResponse", + }, + } + messages := []ProtoMessage{ + { + name: "GetPetRequest", + fields: []ProtoField{ + { + name: "pet_id", + fieldType: "string", + }, + }, + }, + { + name: "Pet", + fields: []ProtoField{ + { + name: "pet_type", + fieldType: "object", + ref: "#/components/schemas/pet.v1.PetType", + enums: []string{ + "PET_TYPE_UNSPECIFIED", + "PET_TYPE_CAT", + "PET_TYPE_DOG", + "PET_TYPE_SNAKE", + "PET_TYPE_HAMSTER", + }, + }, + { + name: "pet_types", + fieldType: "array", + itemsRef: "#/components/schemas/pet.v1.PetType", + }, + { + name: "tags", + fieldType: "array", + itemsType: "string", + }, + { + name: "pet_id", + fieldType: "string", + desc: "pet_id is an auto-generated id for the pet\\nthe id uniquely identifies a pet in the system", + }, + { + name: "name", + fieldType: "string", + }, + { + name: "created_at", + fieldType: "string", + format: "date-time", + }, + { + name: "vet", + fieldType: "object", + ref: "#/components/schemas/pet.v1.Vet", + }, + { + name: "vets", + fieldType: "array", + itemsRef: "#/components/schemas/pet.v1.Vet", + itemsType: "object", + }, + }, + }, + } + + t.Run("RPC", func(t *testing.T) { + for _, rpc := range rpcs { + pathName := "/" + pkgName + "." + serviceName + "/" + rpc.name + path, ok := openAPI.Paths[pathName] + if !ok { + t.Errorf("%s: missing rpc %q", pathName, rpc.name) + } + + if path.Description != rpc.desc { + t.Errorf("%s: expected desc %q but got %q", pathName, rpc.desc, path.Description) + } + + post := path.Post + if post == nil { + t.Errorf("%s: missing post", pathName) + continue + } + + if post.Summary != rpc.name { + t.Errorf("%s: expected summary %q but got %q", pathName, rpc.name, post.Summary) + } + + requestBodyRef := post.RequestBody + if requestBodyRef == nil { + t.Errorf("%s: missing request body", pathName) + continue + } + + // request + { + requestBody := requestBodyRef.Value + if requestBody == nil { + t.Errorf("%s: missing request body", pathName) + continue + } + + mediaType, ok := requestBody.Content["application/json"] + if !ok { + t.Errorf("%s: missing content type", pathName) + continue + } + + if mediaType.Schema == nil { + t.Errorf("%s: missing media type schema", pathName) + continue + } + + expectedRef := "#/components/schemas/" + pkgName + "." + rpc.input + if mediaType.Schema.Ref != expectedRef { + t.Errorf("%s: expected ref %q but got %q", pathName, expectedRef, mediaType.Schema.Ref) + } + } + + // response + { + respRef := post.Responses["200"] + if respRef == nil { + t.Errorf("%s: missing resp", pathName) + continue + } + + resp := respRef.Value + if resp == nil { + t.Errorf("%s: missing resp", pathName) + continue + } + + mediaType, ok := resp.Content["application/json"] + if !ok { + t.Errorf("%s: missing content type", pathName) + continue + } + + if mediaType.Schema == nil { + t.Errorf("%s: missing media type schema", pathName) + continue + } + + expectedRef := "#/components/schemas/" + pkgName + "." + rpc.output + if mediaType.Schema.Ref != expectedRef { + t.Errorf("%s: expected ref %q but got %q", pathName, expectedRef, mediaType.Schema.Ref) + } + } + } + }) + + t.Run("Messages", func(*testing.T) { + for _, message := range messages { + schemaName := "" + pkgName + "." + message.name + schema, ok := openAPI.Components.Schemas[schemaName] + if !ok { + t.Errorf("%s: missing message %q", schemaName, message.name) + } + if schema.Value == nil { + t.Errorf("%s: missing component", schemaName) + continue + } + properties := schema.Value.Properties + for _, messageField := range message.fields { + propertyRef, ok := properties[messageField.name] + if !ok { + t.Errorf("%s: missing property %q", schemaName, messageField.name) + } + + if propertyRef == nil || propertyRef.Value == nil { + t.Errorf("%s: missing property ref", schemaName) + continue + } + + property := propertyRef.Value + if property.Type != messageField.fieldType { + t.Errorf("%s: %q expected property type %q but got %q", schemaName, message.name, messageField.fieldType, property.Type) + continue + } + + if messageField.format != "" { + if messageField.format != "" && property.Format != messageField.format { + t.Errorf("%s: expected property format %q but got %q", schemaName, messageField.format, property.Format) + continue + } + } + + if propertyRef.Ref != messageField.ref { + t.Errorf("%s: %q expected reference %q but got %q", schemaName, messageField.name, messageField.ref, propertyRef.Ref) + } + + // check the reference schema + if messageField.ref != "" { + refParts := strings.Split(messageField.ref, "/") + // the reference schema has the format of #/components/schemas/ so we need to get the last part + schemaRef, ok := openAPI.Components.Schemas[refParts[len(refParts)-1]] + if !ok { + t.Errorf("%s: %q expected reference schema %q but got nil", schemaName, messageField.name, messageField.ref) + } else { + // check if the schema reference has the expected enum values + if len(messageField.enums) > 0 { + if schemaRef.Value.Enum == nil { + t.Errorf("%s: %q expected reference schema enums %q but got nil", schemaName, messageField.name, messageField.ref) + } else { + enums := map[string]struct{}{} + for _, e := range schemaRef.Value.Enum { + enums[e.(string)] = struct{}{} + } + for _, e := range messageField.enums { + if _, ok := enums[e]; !ok { + t.Errorf("%s: %q expected reference schema enum %q to have %q but got nil", schemaName, messageField.name, messageField.ref, e) + } + } + } + } + } + } + + if property.Type == "array" { + if property.Items == nil || property.Items.Value == nil { + t.Errorf("%s: missing property enum array items", schemaName) + } + // only check the array items type if it's not a reference + if messageField.itemsRef == "" && (property.Items.Value.Type != messageField.itemsType) { + t.Errorf("%s: expected %s items type %q but got %q", schemaName, messageField.name, messageField.itemsType, property.Items.Value.Type) + } + // check the array items reference schema + if property.Items.Ref != messageField.itemsRef { + t.Errorf("%s: expected %s items ref %q but got %q", schemaName, messageField.name, messageField.itemsRef, property.Items.Ref) + } + } + } + } + }) +} diff --git a/cmd/twirp-openapi-gen/internal/generator/handlers.go b/cmd/twirp-openapi-gen/internal/generator/handlers.go new file mode 100644 index 0000000..c11eb46 --- /dev/null +++ b/cmd/twirp-openapi-gen/internal/generator/handlers.go @@ -0,0 +1,562 @@ +package generator + +import ( + "encoding/json" + "fmt" + "log" + "log/slog" + "path/filepath" + "strings" + "unicode" + + "github.com/emicklei/proto" + "github.com/getkin/kin-openapi/openapi3" +) + +const ( + googleAnyType = "google.protobuf.Any" + googleListValueType = "google.protobuf.ListValue" + googleStructType = "google.protobuf.Struct" + googleValueType = "google.protobuf.Value" + + googleMoneyType = "google.type.Money" +) + +var ( + successDescription = "Success" +) + +func (gen *generator) Handlers() []proto.Handler { + return []proto.Handler{ + proto.WithPackage(gen.Package), + proto.WithImport(gen.Import), + proto.WithRPC(gen.RPC), + proto.WithEnum(gen.Enum), + proto.WithMessage(gen.Message), + } +} + +func (gen *generator) Package(pkg *proto.Package) { + slog.Debug("Package handler", "package", pkg.Name) + gen.packageName = pkg.Name +} + +func (gen *generator) Import(i *proto.Import) { + slog.Debug("Import handler", "package", gen.packageName, "filename", i.Filename) + + if _, ok := gen.importedFiles[i.Filename]; ok { + return + } + gen.importedFiles[i.Filename] = struct{}{} + + // Instead of loading and generating the OpenAPI docs for the google proto definitions, + // its known types are mapped to OpenAPI types; see aliases.go. + if strings.Contains(i.Filename, "google/") { + return + } + + protoFile, err := readProtoFile(i.Filename, gen.conf.protoPaths) + if err != nil { + slog.Error("could not import file", "filename", i.Filename, "error", err) + return + } + + oldPackageName := gen.packageName + + // Override the package name for the next round of Walk calls to preserve the types full import path + withPackage := func(pkg *proto.Package) { + gen.packageName = pkg.Name + } + + // additional files walked for messages and imports only + proto.Walk(protoFile, + proto.WithPackage(withPackage), + proto.WithImport(gen.Import), + proto.WithRPC(gen.RPC), + proto.WithEnum(gen.Enum), + proto.WithMessage(gen.Message), + ) + + gen.packageName = oldPackageName +} + +func (gen *generator) RPC(rpc *proto.RPC) { + slog.Debug("RPC handler", "package", gen.packageName, "rpc", rpc.Name, "requestType", rpc.RequestType, "returnsType", rpc.ReturnsType) + + parent, ok := rpc.Parent.(*proto.Service) + if !ok { + log.Panicf("parent is not proto.service") + } + pathName := filepath.Join("/"+gen.conf.pathPrefix+"/", gen.packageName+"."+parent.Name, rpc.Name) + + var reqMediaType *openapi3.MediaType + switch rpc.RequestType { + case "google.protobuf.Empty": + reqMediaType = openapi3.NewMediaType() + default: + if strings.Contains(rpc.RequestType, ".") { + reqMediaType = &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: fmt.Sprintf("#/components/schemas/%s", rpc.RequestType), + }, + } + } else { + reqMediaType = &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: fmt.Sprintf("#/components/schemas/%s.%s", gen.packageName, rpc.RequestType), + }, + } + } + } + + var resMediaType *openapi3.MediaType + switch rpc.ReturnsType { + case "google.protobuf.Empty": + resMediaType = openapi3.NewMediaType() + default: + if strings.Contains(rpc.ReturnsType, ".") { + resMediaType = &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: fmt.Sprintf("#/components/schemas/%s", rpc.ReturnsType), + }, + } + } else { + resMediaType = &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: fmt.Sprintf("#/components/schemas/%s.%s", gen.packageName, rpc.ReturnsType), + }, + } + } + } + + // NOTE: Redocly does not read the "examples" (plural) field, only the "example" (singular) one. + commentMsg, reqExamples, resExamples, err := parseComment(rpc.Comment) + if err != nil { + // TODO(dm): how can we surface the errors from the parser instead of panicking? + log.Panicf("failed to parse comment %s ", err) + } + + if len(reqExamples) > 0 { + exampleObj := make(map[string]interface{}) + for i, example := range reqExamples { + exampleObj[fmt.Sprintf("example %d", i)] = example + } + reqMediaType.Example = exampleObj + } + if len(resExamples) > 0 { + exampleObj := make(map[string]interface{}) + for i, example := range resExamples { + exampleObj[fmt.Sprintf("example %d", i)] = example + } + resMediaType.Example = exampleObj + } + + gen.openAPIV3.Paths[pathName] = &openapi3.PathItem{ + Post: &openapi3.Operation{ + Description: commentMsg, + Summary: rpc.Name, + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{"application/json": reqMediaType}, + }, + }, + Responses: map[string]*openapi3.ResponseRef{ + "200": { + Value: &openapi3.Response{ + Description: &successDescription, + Content: openapi3.Content{"application/json": resMediaType}, + }, + }, + }, + }, + } +} + +func (gen *generator) Enum(enum *proto.Enum) { + slog.Debug("Enum handler", "package", gen.packageName, "enum", enum.Name) + values := []interface{}{} + for _, element := range enum.Elements { + enumField := element.(*proto.EnumField) + values = append(values, enumField.Name) + } + + gen.openAPIV3.Components.Schemas[gen.packageName+"."+enum.Name] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: description(enum.Comment), + Type: "string", + Enum: values, + }, + } +} + +func (gen *generator) Message(msg *proto.Message) { + slog.Debug("Message handler", "package", gen.packageName, "message", msg.Name) + + schemaProps := openapi3.Schemas{} + + for _, element := range msg.Elements { + switch val := element.(type) { + case *proto.Message: + //logger.logd("proto.Message") + gen.Message(val) + case *proto.Comment: + //logger.logd("proto.Comment") + case *proto.Oneof: + //logger.logd("proto.Oneof") + case *proto.OneOfField: + //logger.logd("proto.OneOfField") + gen.addField(schemaProps, val.Field, false) + case *proto.MapField: + //logger.logd("proto.MapField") + gen.addField(schemaProps, val.Field, false) + case *proto.NormalField: + //logger.logd("proto.NormalField %q %q", val.Field.Type, val.Field.Name) + gen.addField(schemaProps, val.Field, val.Repeated) + default: + slog.Error("unknown field type", "type", fmt.Sprintf("%T", element)) + } + } + + gen.openAPIV3.Components.Schemas[gen.packageName+"."+msg.Name] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: description(msg.Comment), + Type: "object", + Properties: schemaProps, + }, + } +} + +func (gen *generator) addField(schemaPropsV3 openapi3.Schemas, field *proto.Field, repeated bool) { + fieldDescription := description(field.Comment) + fieldName := field.Name + fieldType := field.Type + fieldFormat := field.Type + // map proto types to openapi + if p, ok := typeAliases[fieldType]; ok { + fieldType = p.Type + fieldFormat = p.Format + } + + if fieldType == fieldFormat { + fieldFormat = "" + } + + switch fieldType { + // Build the schema for native types that don't need to reference other schemas + // https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#data-types + case "boolean", "integer", "number", "string", "object": + fieldSchemaV3 := openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: fieldDescription, + Type: fieldType, + Format: fieldFormat, + }, + } + if !repeated { + schemaPropsV3[fieldName] = &fieldSchemaV3 + return + } + schemaPropsV3[fieldName] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: fieldDescription, + Type: "array", + Format: fieldFormat, + Items: &fieldSchemaV3, + }, + } + return + + // generate the schema for google well known complex types: https://protobuf.dev/reference/protobuf/google.protobuf/#index + case googleAnyType: + slog.Debug("any", "name", fieldName, "type", fieldType, "format", fieldFormat) + gen.addGoogleAnySchema() + case googleListValueType: + slog.Debug("ListValue", "name", fieldName, "type", fieldType, "format", fieldFormat) + gen.addGoogleListValueSchema() + case googleStructType: + slog.Debug("Struct", "name", fieldName, "type", fieldType, "format", fieldFormat) + gen.addGoogleValueSchema() // struct depends on value + gen.addGoogleStructSchema() + case googleValueType: + slog.Debug("Value", "name", fieldName, "type", fieldType, "format", fieldFormat) + gen.addGoogleValueSchema() + case googleMoneyType: + slog.Debug("Money", "name", fieldName, "type", fieldType, "format", fieldFormat) + gen.addGoogleMoneySchema() + default: + slog.Debug("Default", "name", fieldName, "type", fieldType, "format", fieldFormat) + } + + // prefix custom types with the package name + ref := fmt.Sprintf("#/components/schemas/%s", fieldType) + if !strings.Contains(fieldType, ".") { + ref = fmt.Sprintf("#/components/schemas/%s.%s", gen.packageName, fieldType) + } + + if !repeated { + schemaPropsV3[fieldName] = &openapi3.SchemaRef{ + Ref: ref, + Value: &openapi3.Schema{ + Description: fieldDescription, + Type: "object", + }, + } + return + } + + schemaPropsV3[fieldName] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: fieldDescription, + Type: "array", + Items: &openapi3.SchemaRef{ + Ref: ref, + Value: &openapi3.Schema{ + Type: "object", + }, + }, + }, + } +} + +// addGoogleAnySchema adds a schema item for the google.protobuf.Any type. +func (gen *generator) addGoogleAnySchema() { + if _, ok := gen.openAPIV3.Components.Schemas[googleAnyType]; ok { + return + } + gen.openAPIV3.Components.Schemas[googleAnyType] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: ` +The JSON representation of an Any value uses the regular +representation of the deserialized, embedded message, with an +additional field @type which contains the type URL. Example: + + package google.profile; + message Person { + string first_name = 1; + string last_name = 2; + } + + { + "@type": "type.googleapis.com/google.profile.Person", + "firstName": , + "lastName": + } + +If the embedded message type is well-known and has a custom JSON +representation, that representation will be embedded adding a field +value which holds the custom JSON in addition to the @type +field. Example (for message [google.protobuf.Duration][]): + + { + "@type": "type.googleapis.com/google.protobuf.Duration", + "value": "1.212s" + } +`, + Type: "object", + Properties: openapi3.Schemas{ + "@type": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: "", + Type: "string", + Format: "", + }, + }, + }, + }, + } +} + +// addGoogleAnySchema adds a schema item for the google.protobuf.ListValue type. +func (gen *generator) addGoogleListValueSchema() { + if _, ok := gen.openAPIV3.Components.Schemas[googleListValueType]; ok { + return + } + gen.openAPIV3.Components.Schemas[googleListValueType] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: ` +ListValue is a wrapper around a repeated field of values. +The JSON representation for ListValue is JSON array. +`, + Type: "array", + Items: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + OneOf: openapi3.SchemaRefs{ + &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: "string", + }, + }, + &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: "number", + }, + }, + &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: "integer", + }, + }, + &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: "boolean", + }, + }, + &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: "array", + }, + }, + &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: "object", + }, + }, + }, + }, + }, + }, + } +} + +func (gen *generator) addGoogleStructSchema() { + if _, ok := gen.openAPIV3.Components.Schemas[googleStructType]; ok { + return + } + + gen.openAPIV3.Components.Schemas[googleStructType] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: ` +Struct represents a structured data value, consisting of fields +which map to dynamically typed values. In some languages, +Struct might be supported by a native representation. For example, +in scripting languages like JS a struct is represented as +an object. The details of that representation are described +together with the proto support for the language. + +The JSON representation for Struct is JSON object. +`, + Type: "object", + Properties: openapi3.Schemas{ + "fields": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: "Unordered map of dynamically typed values.", + Type: "object", + AdditionalProperties: openapi3.AdditionalProperties{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/google.protobuf.Value", + }, + }, + }, + }, + }, + }, + } +} + +func (gen *generator) addGoogleValueSchema() { + if _, ok := gen.openAPIV3.Components.Schemas[googleValueType]; ok { + return + } + + gen.openAPIV3.Components.Schemas[googleValueType] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: ` +Value represents a dynamically typed value which can be either +null, a number, a string, a boolean, a recursive struct value, or a +list of values. A producer of value is expected to set one of that +variants, absence of any variant indicates an error. + +The JSON representation for Value is JSON value. +`, + OneOf: openapi3.SchemaRefs{ + &openapi3.SchemaRef{Value: &openapi3.Schema{Type: "string"}}, + &openapi3.SchemaRef{Value: &openapi3.Schema{Type: "number"}}, + &openapi3.SchemaRef{Value: &openapi3.Schema{Type: "integer"}}, + &openapi3.SchemaRef{Value: &openapi3.Schema{Type: "boolean"}}, + &openapi3.SchemaRef{Ref: "#/components/schemas/google.protobuf.Struct"}, + &openapi3.SchemaRef{Ref: "#/components/schemas/google.protobuf.ListValue"}, + }, + }, + } +} + +func (gen *generator) addGoogleMoneySchema() { + if _, ok := gen.openAPIV3.Components.Schemas[googleMoneyType]; ok { + return + } + + gen.openAPIV3.Components.Schemas[googleMoneyType] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: `Represents an amount of money with its currency type`, + Type: "object", + Properties: openapi3.Schemas{ + "currency_code": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: "The 3-letter currency code defined in ISO 4217.", + Type: "string", + }, + }, + "units": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: "The whole units of the amount.\nFor example if `currencyCode` is `\"USD\"`, then 1 unit is one US dollar.", + Type: "integer", + Format: "int64", + }, + }, + "nanos": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: "Number of nano (10^-9) units of the amount.\nThe value must be between -999,999,999 and +999,999,999 inclusive.\nIf `units` is positive, `nanos` must be positive or zero.\nIf `units` is zero, `nanos` can be positive, zero, or negative.\nIf `units` is negative, `nanos` must be negative or zero.\nFor example $-1.75 is represented as `units`=-1 and `nanos`=-750,000,000.", + Type: "integer", + Format: "int32", + }, + }, + }, + }, + } +} + +func description(comment *proto.Comment) string { + if comment == nil { + return "" + } + result := []string{} + for _, line := range comment.Lines { + line = strings.TrimSpace(line) + if len(line) > 0 { + result = append(result, line) + } + } + return strings.Join(result, "\n") +} + +// parseComment parses the comment for an RPC method and returns the description, request examples, and response examples. +// it looks for the labels req-example: and res-example: to extract the JSON payload samples. +func parseComment(comment *proto.Comment) (string, []map[string]interface{}, []map[string]interface{}, error) { + if comment == nil { + return "", nil, nil, nil + } + reqExamples := []map[string]interface{}{} + respExamples := []map[string]interface{}{} + message := "" + for _, line := range comment.Lines { + line = strings.TrimLeftFunc(line, unicode.IsSpace) + if strings.HasPrefix(line, "req-example:") { + parts := strings.Split(line, "req-example:") + example := map[string]interface{}{} + if err := json.Unmarshal([]byte(parts[1]), &example); err != nil { + return "", nil, nil, fmt.Errorf("failed to parse req-example %q: %v", parts[1], err) + } + reqExamples = append(reqExamples, example) + } else if strings.HasPrefix(line, "res-example:") { + parts := strings.Split(line, "res-example:") + example := map[string]interface{}{} + if err := json.Unmarshal([]byte(parts[1]), &example); err != nil { + return "", nil, nil, fmt.Errorf("failed to parse res-example %q: %v", parts[1], err) + } + respExamples = append(respExamples, example) + } else { + message = fmt.Sprintf("%s\n%s", message, line) + } + } + return message, reqExamples, respExamples, nil +} diff --git a/cmd/twirp-openapi-gen/internal/generator/testdata/doc.json b/cmd/twirp-openapi-gen/internal/generator/testdata/doc.json new file mode 100644 index 0000000..afe6c6d --- /dev/null +++ b/cmd/twirp-openapi-gen/internal/generator/testdata/doc.json @@ -0,0 +1,405 @@ +{ + "components": { + "schemas": { + "google.protobuf.Any": { + "description": "\nThe JSON representation of an Any value uses the regular\nrepresentation of the deserialized, embedded message, with an\nadditional field @type which contains the type URL. Example:\n\n\tpackage google.profile;\n\tmessage Person {\n\t string first_name = 1;\n\t string last_name = 2;\n\t}\n\n\t{\n\t \"@type\": \"type.googleapis.com/google.profile.Person\",\n\t \"firstName\": \u003cstring\u003e,\n\t \"lastName\": \u003cstring\u003e\n\t}\n\nIf the embedded message type is well-known and has a custom JSON\nrepresentation, that representation will be embedded adding a field\nvalue which holds the custom JSON in addition to the @type\nfield. Example (for message [google.protobuf.Duration][]):\n\n\t{\n\t \"@type\": \"type.googleapis.com/google.protobuf.Duration\",\n\t \"value\": \"1.212s\"\n\t}\n", + "properties": { + "@type": { + "type": "string" + } + }, + "type": "object" + }, + "google.protobuf.ListValue": { + "description": "\nListValue is a wrapper around a repeated field of values.\nThe JSON representation for ListValue is JSON array.\n", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "boolean" + }, + { + "type": "array" + }, + { + "type": "object" + } + ] + }, + "type": "array" + }, + "google.protobuf.Struct": { + "description": "\nStruct represents a structured data value, consisting of fields\nwhich map to dynamically typed values. In some languages, \nStruct might be supported by a native representation. For example,\nin scripting languages like JS a struct is represented as\nan object. The details of that representation are described\ntogether with the proto support for the language.\n\nThe JSON representation for Struct is JSON object.\n", + "properties": { + "fields": { + "additionalProperties": { + "$ref": "#/components/schemas/google.protobuf.Value" + }, + "description": "Unordered map of dynamically typed values.", + "type": "object" + } + }, + "type": "object" + }, + "google.protobuf.Value": { + "description": "\nValue represents a dynamically typed value which can be either\nnull, a number, a string, a boolean, a recursive struct value, or a\nlist of values. A producer of value is expected to set one of that\nvariants, absence of any variant indicates an error.\n\t\t\t\t\nThe JSON representation for Value is JSON value.\n", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "boolean" + }, + { + "$ref": "#/components/schemas/google.protobuf.Struct" + }, + { + "$ref": "#/components/schemas/google.protobuf.ListValue" + } + ] + }, + "google.type.Money": { + "description": "Represents an amount of money with its currency type", + "properties": { + "currency_code": { + "description": "The 3-letter currency code defined in ISO 4217.", + "type": "string" + }, + "nanos": { + "description": "Number of nano (10^-9) units of the amount.\nThe value must be between -999,999,999 and +999,999,999 inclusive.\nIf `units` is positive, `nanos` must be positive or zero.\nIf `units` is zero, `nanos` can be positive, zero, or negative.\nIf `units` is negative, `nanos` must be negative or zero.\nFor example $-1.75 is represented as `units`=-1 and `nanos`=-750,000,000.", + "format": "int32", + "type": "integer" + }, + "units": { + "description": "The whole units of the amount.\nFor example if `currencyCode` is `\"USD\"`, then 1 unit is one US dollar.", + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "payment.v1alpha1.Order": { + "description": "Order represents a monetary order.", + "properties": { + "amount": { + "$ref": "#/components/schemas/google.type.Money" + }, + "order_id": { + "type": "string" + }, + "payment_provider": { + "$ref": "#/components/schemas/payment.v1alpha1.PaymentProvider" + }, + "recipient_id": { + "type": "string" + } + }, + "type": "object" + }, + "payment.v1alpha1.PaymentProvider": { + "description": "PaymentProvider represents the supported set\nof payment providers.", + "enum": [ + "PAYMENT_PROVIDER_UNSPECIFIED", + "PAYMENT_PROVIDER_STRIPE", + "PAYMENT_PROVIDER_PAYPAL", + "PAYMENT_PROVIDER_APPLE" + ], + "type": "string" + }, + "pet.v1.DeletePetRequest": { + "properties": { + "pet_id": { + "type": "string" + } + }, + "type": "object" + }, + "pet.v1.GetPetRequest": { + "description": "GetPetRequest is the request object for GetPet\nThe message accepts a pet id as an input", + "properties": { + "pet_id": { + "type": "string" + } + }, + "type": "object" + }, + "pet.v1.GetPetResponse": { + "properties": { + "pet": { + "$ref": "#/components/schemas/pet.v1.Pet" + } + }, + "type": "object" + }, + "pet.v1.Pet": { + "description": "Pet represents a pet in the pet store.", + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "details": { + "items": { + "$ref": "#/components/schemas/google.protobuf.Any" + }, + "type": "array" + }, + "labels": { + "$ref": "#/components/schemas/google.protobuf.ListValue" + }, + "metadata": { + "$ref": "#/components/schemas/google.protobuf.Struct" + }, + "name": { + "type": "string" + }, + "payment_provider": { + "$ref": "#/components/schemas/payment.v1alpha1.PaymentProvider" + }, + "pet_id": { + "description": "pet_id is an auto-generated id for the pet\nthe id uniquely identifies a pet in the system", + "type": "string" + }, + "pet_type": { + "$ref": "#/components/schemas/pet.v1.PetType" + }, + "pet_types": { + "items": { + "$ref": "#/components/schemas/pet.v1.PetType" + }, + "type": "array" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array" + }, + "vet": { + "$ref": "#/components/schemas/pet.v1.Vet" + }, + "vets": { + "items": { + "$ref": "#/components/schemas/pet.v1.Vet" + }, + "type": "array" + } + }, + "type": "object" + }, + "pet.v1.PetType": { + "description": "PetType represents the different types of pets in the pet store.", + "enum": [ + "PET_TYPE_UNSPECIFIED", + "PET_TYPE_CAT", + "PET_TYPE_DOG", + "PET_TYPE_SNAKE", + "PET_TYPE_HAMSTER" + ], + "type": "string" + }, + "pet.v1.PurchasePetRequest": { + "properties": { + "order": { + "$ref": "#/components/schemas/payment.v1alpha1.Order" + }, + "pet_id": { + "type": "string" + } + }, + "type": "object" + }, + "pet.v1.PurchasePetResponse": { + "type": "object" + }, + "pet.v1.PutPetRequest": { + "properties": { + "name": { + "type": "string" + }, + "pet_type": { + "$ref": "#/components/schemas/pet.v1.PetType" + } + }, + "type": "object" + }, + "pet.v1.PutPetResponse": { + "properties": { + "pet": { + "$ref": "#/components/schemas/pet.v1.Pet" + } + }, + "type": "object" + }, + "pet.v1.UpdatePetRequest": { + "properties": { + "metadata": { + "$ref": "#/components/schemas/google.protobuf.Struct" + }, + "pet_id": { + "type": "string" + } + }, + "type": "object" + }, + "pet.v1.UpdatePetResponse": { + "properties": { + "pet": { + "$ref": "#/components/schemas/pet.v1.Pet" + } + }, + "type": "object" + }, + "pet.v1.Vet": { + "properties": { + "name": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "info": { + "title": "Test", + "version": "0.1" + }, + "openapi": "3.0.0", + "paths": { + "/pet.v1.PetStoreService/DeletePet": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pet.v1.DeletePetRequest" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": {} + }, + "description": "Success" + } + }, + "summary": "DeletePet" + } + }, + "/pet.v1.PetStoreService/GetPet": { + "post": { + "description": "\nGetPet returns details about a pet\nIt accepts a pet id as an input and returns back the matching pet object", + "requestBody": { + "content": { + "application/json": { + "example": { + "example 0": { + "pet_id": "123" + }, + "example 1": { + "pet_id": "456" + } + }, + "schema": { + "$ref": "#/components/schemas/pet.v1.GetPetRequest" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "example 0": { + "pet": { + "name": "toby" + } + } + }, + "schema": { + "$ref": "#/components/schemas/pet.v1.GetPetResponse" + } + } + }, + "description": "Success" + } + }, + "summary": "GetPet" + } + }, + "/pet.v1.PetStoreService/PurchasePet": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pet.v1.PurchasePetRequest" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pet.v1.PurchasePetResponse" + } + } + }, + "description": "Success" + } + }, + "summary": "PurchasePet" + } + }, + "/pet.v1.PetStoreService/UpdatePet": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pet.v1.UpdatePetRequest" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pet.v1.UpdatePetResponse" + } + } + }, + "description": "Success" + } + }, + "summary": "UpdatePet" + } + } + }, + "servers": [ + { + "url": "https://example.com" + } + ] +} \ No newline at end of file diff --git a/cmd/twirp-openapi-gen/internal/generator/testdata/gen/go/payment/v1alpha1/payment.pb.go b/cmd/twirp-openapi-gen/internal/generator/testdata/gen/go/payment/v1alpha1/payment.pb.go new file mode 100644 index 0000000..6b84da8 --- /dev/null +++ b/cmd/twirp-openapi-gen/internal/generator/testdata/gen/go/payment/v1alpha1/payment.pb.go @@ -0,0 +1,263 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.31.0 +// protoc (unknown) +// source: payment/v1alpha1/payment.proto + +package v1alpha1 + +import ( + money "google.golang.org/genproto/googleapis/type/money" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// PaymentProvider represents the supported set +// of payment providers. +type PaymentProvider int32 + +const ( + PaymentProvider_PAYMENT_PROVIDER_UNSPECIFIED PaymentProvider = 0 + PaymentProvider_PAYMENT_PROVIDER_STRIPE PaymentProvider = 1 + PaymentProvider_PAYMENT_PROVIDER_PAYPAL PaymentProvider = 2 + PaymentProvider_PAYMENT_PROVIDER_APPLE PaymentProvider = 3 +) + +// Enum value maps for PaymentProvider. +var ( + PaymentProvider_name = map[int32]string{ + 0: "PAYMENT_PROVIDER_UNSPECIFIED", + 1: "PAYMENT_PROVIDER_STRIPE", + 2: "PAYMENT_PROVIDER_PAYPAL", + 3: "PAYMENT_PROVIDER_APPLE", + } + PaymentProvider_value = map[string]int32{ + "PAYMENT_PROVIDER_UNSPECIFIED": 0, + "PAYMENT_PROVIDER_STRIPE": 1, + "PAYMENT_PROVIDER_PAYPAL": 2, + "PAYMENT_PROVIDER_APPLE": 3, + } +) + +func (x PaymentProvider) Enum() *PaymentProvider { + p := new(PaymentProvider) + *p = x + return p +} + +func (x PaymentProvider) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (PaymentProvider) Descriptor() protoreflect.EnumDescriptor { + return file_payment_v1alpha1_payment_proto_enumTypes[0].Descriptor() +} + +func (PaymentProvider) Type() protoreflect.EnumType { + return &file_payment_v1alpha1_payment_proto_enumTypes[0] +} + +func (x PaymentProvider) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use PaymentProvider.Descriptor instead. +func (PaymentProvider) EnumDescriptor() ([]byte, []int) { + return file_payment_v1alpha1_payment_proto_rawDescGZIP(), []int{0} +} + +// Order represents a monetary order. +type Order struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + OrderId string `protobuf:"bytes,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"` + RecipientId string `protobuf:"bytes,2,opt,name=recipient_id,json=recipientId,proto3" json:"recipient_id,omitempty"` + Amount *money.Money `protobuf:"bytes,3,opt,name=amount,proto3" json:"amount,omitempty"` + PaymentProvider PaymentProvider `protobuf:"varint,4,opt,name=payment_provider,json=paymentProvider,proto3,enum=payment.v1alpha1.PaymentProvider" json:"payment_provider,omitempty"` +} + +func (x *Order) Reset() { + *x = Order{} + if protoimpl.UnsafeEnabled { + mi := &file_payment_v1alpha1_payment_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Order) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Order) ProtoMessage() {} + +func (x *Order) ProtoReflect() protoreflect.Message { + mi := &file_payment_v1alpha1_payment_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Order.ProtoReflect.Descriptor instead. +func (*Order) Descriptor() ([]byte, []int) { + return file_payment_v1alpha1_payment_proto_rawDescGZIP(), []int{0} +} + +func (x *Order) GetOrderId() string { + if x != nil { + return x.OrderId + } + return "" +} + +func (x *Order) GetRecipientId() string { + if x != nil { + return x.RecipientId + } + return "" +} + +func (x *Order) GetAmount() *money.Money { + if x != nil { + return x.Amount + } + return nil +} + +func (x *Order) GetPaymentProvider() PaymentProvider { + if x != nil { + return x.PaymentProvider + } + return PaymentProvider_PAYMENT_PROVIDER_UNSPECIFIED +} + +var File_payment_v1alpha1_payment_proto protoreflect.FileDe