aboutsummaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
authorXe Iaso <me@xeiaso.net>2024-02-24 14:48:23 -0500
committerXe Iaso <me@xeiaso.net>2024-02-24 14:50:50 -0500
commitb27ea6b8587f6735a309e47e1abcf3acd12f7953 (patch)
tree1c943a6a73fc1e7fbbaaa50852881f4d35252882 /cmd
parent4bca52442d62a5dee3d8b533b48df0223e1679b0 (diff)
downloadxesite-b27ea6b8587f6735a309e47e1abcf3acd12f7953.tar.xz
xesite-b27ea6b8587f6735a309e47e1abcf3acd12f7953.zip
cmd: import twirp-openapi-gen, fix some bugs
Signed-off-by: Xe Iaso <me@xeiaso.net>
Diffstat (limited to 'cmd')
-rw-r--r--cmd/twirp-openapi-gen/LICENSE201
-rw-r--r--cmd/twirp-openapi-gen/README.md3
-rw-r--r--cmd/twirp-openapi-gen/internal/generator/aliases.go85
-rw-r--r--cmd/twirp-openapi-gen/internal/generator/generator.go187
-rw-r--r--cmd/twirp-openapi-gen/internal/generator/generator_test.go317
-rw-r--r--cmd/twirp-openapi-gen/internal/generator/handlers.go562
-rw-r--r--cmd/twirp-openapi-gen/internal/generator/testdata/doc.json405
-rw-r--r--cmd/twirp-openapi-gen/internal/generator/testdata/gen/go/payment/v1alpha1/payment.pb.go263
-rw-r--r--cmd/twirp-openapi-gen/internal/generator/testdata/gen/go/pet/v1/pet.pb.go896
-rw-r--r--cmd/twirp-openapi-gen/internal/generator/testdata/gen/go/pet/v1/pet.twirp.go1705
-rw-r--r--cmd/twirp-openapi-gen/internal/generator/testdata/paymentapis/payment/v1alpha1/payment.proto24
-rw-r--r--cmd/twirp-openapi-gen/internal/generator/testdata/pet-api-doc.json321
-rw-r--r--cmd/twirp-openapi-gen/internal/generator/testdata/pet-api-doc.yaml297
-rw-r--r--cmd/twirp-openapi-gen/internal/generator/testdata/petapis/pet/v1/pet.proto115
-rw-r--r--cmd/twirp-openapi-gen/main.go90
15 files changed, 5471 insertions, 0 deletions
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/<type> 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()