diff --git a/CLAUDE.md b/CLAUDE.md index 9327530..d9e66f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ go.yaml.in/yaml/v3. No cgo. | `internal/parser` | kin-openapi document → `codegen` structs: models, operations, parameters | | `internal/codegen` | the `Codegen*` structs templates render; fields mirror the Java originals | | `internal/gen` | the pipeline: load spec → parse → render models, apis, supporting files | -| `internal/generator` | `CodegenConfig` and the language configs, `typescript/` and `dart/` | +| `internal/generator` | `CodegenConfig` and the language configs, `typescript/`, `dart/`, `golang/` | | `internal/template` | the Mustache engine: partials, lambdas, struct-to-map via json | | `internal/config` | generator options and their config-file keys | | `internal/update` | release check, verified download, self-update | diff --git a/Makefile b/Makefile index c36e7b5..fabe9ac 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help test cover cover-html lint fmt vet build install clean tidy update samples-dart-fetch regress all +.PHONY: help test cover cover-html lint fmt vet build install clean tidy update samples-dart-fetch samples-go-fetch regress all GOFUMPT ?= go run mvdan.cc/gofumpt@v0.12.0 GOLANGCI ?= go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.0 @@ -72,6 +72,12 @@ samples-dart-fetch: build @rm -rf samples/dart-fetch/petstore-expanded $(OUTPUT_DIR)/openapi-generator generate -c samples/dart-fetch/config.yaml +## samples-go-fetch: Regenerate the go-fetch petstore sample +samples-go-fetch: build + @echo "Generating go-fetch petstore sample..." + @rm -rf samples/go-fetch/petstore-expanded + $(OUTPUT_DIR)/openapi-generator generate -c samples/go-fetch/config.yaml + ## regress: Diff generated output against a git ref (default HEAD): make regress REF=master regress: ./scripts/regress.sh $(REF) diff --git a/README.md b/README.md index 422ef98..92f5d68 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,11 @@ minimal dependencies. - Fast generation times without JVM overhead - Load specs from local files or remote URLs - Command-line interface compatible with the original OpenAPI Generator -- Supports OpenAPI 3.x and Swagger 2.0 specifications (auto-converts 2.0 -> 3.x) +- Supports OpenAPI 3.x and Swagger 2.0 specifications, JSON or YAML + (auto-converts 2.0 -> 3.x); a 3.1 `null` member of `anyOf`/`oneOf`, a `type` + list with `null`, a `const` and a `contentMediaType` binary string read as + their 3.0 forms, as the upstream normalizer's defaults do; 3.2's `itemSchema` + of a streamed response is accepted in a 3.1 document, as FastAPI writes it ## Installation @@ -47,6 +51,7 @@ go install github.com/xseman/openapi-generator/cmd/openapi-generator@latest - [typescript-fetch](./templates/typescript-fetch/README.md) - [dart-fetch](./samples/dart-fetch/README.md) +- [go-fetch](./templates/go-fetch/README.md) ### Server @@ -75,16 +80,16 @@ openapi-generator validate -i openapi.yaml --recommend ### `generate` -| Option | Short | Description | -| --------------------------- | ----- | ------------------------------------------------ | -| `--input-spec` | `-i` | Location of the OpenAPI spec (file or URL) | -| `--generator-name` | `-g` | Generator to use: typescript-fetch, dart-fetch | -| `--output` | `-o` | Output directory | -| `--config` | `-c` | Configuration file (JSON/YAML) | -| `--template-dir` | `-t` | Custom template directory | -| `--additional-properties` | `-p` | Key=value pairs for generator options | -| `--skip-validate-spec` | | Skip OpenAPI spec validation | -| `--verbose` | `-v` | Enable verbose output | +| Option | Short | Description | +| ------------------------- | ----- | ---------------------------------------------------- | +| `--input-spec` | `-i` | Location of the OpenAPI spec (file or URL) | +| `--generator-name` | `-g` | typescript-fetch, dart-fetch, go-fetch | +| `--output` | `-o` | Output directory | +| `--config` | `-c` | Configuration file (JSON/YAML) | +| `--template-dir` | `-t` | Custom template directory | +| `--additional-properties` | `-p` | `key=value` generator options, `a=1,b=2` or repeated | +| `--skip-validate-spec` | | Skip OpenAPI spec validation | +| `--verbose` | `-v` | Enable verbose output | ### `validate` @@ -163,6 +168,7 @@ sequenceDiagram CLI->>Parser: Load Spec (YAML/JSON) Parser-->>Parser: Convert Swagger 2.0 to OpenAPI 3.x + Parser-->>Parser: Normalize 3.1 null and binary schemas Parser-->>CLI: Parsed Spec CLI->>Config: Merge CLI + File Config diff --git a/cmd/openapi-generator/main.go b/cmd/openapi-generator/main.go index 66cdaa7..6cacd21 100644 --- a/cmd/openapi-generator/main.go +++ b/cmd/openapi-generator/main.go @@ -105,6 +105,7 @@ var listCmd = &cobra.Command{ fmt.Println("CLIENT generators:") fmt.Println(" - typescript-fetch") fmt.Println(" - dart-fetch") + fmt.Println(" - go-fetch") fmt.Println() }, } @@ -123,6 +124,8 @@ var configHelpCmd = &cobra.Command{ gen.PrintTypeScriptFetchConfigHelp() case "dart-fetch": gen.PrintDartFetchConfigHelp() + case "go-fetch": + gen.PrintGoFetchConfigHelp() default: fmt.Printf("Unknown generator: %s\n", args[0]) } diff --git a/go.mod b/go.mod index c5eaa6e..75fefe9 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( github.com/cbroglie/mustache v1.4.2 github.com/getkin/kin-openapi v0.152.0 + github.com/oasdiff/yaml v0.1.1 github.com/spf13/cobra v1.10.2 go.yaml.in/yaml/v3 v3.0.4 ) @@ -14,7 +15,6 @@ require ( github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect - github.com/oasdiff/yaml v0.1.1 // indirect github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/spf13/pflag v1.0.9 // indirect diff --git a/internal/codegen/parameter.go b/internal/codegen/parameter.go index fc63774..6ec2045 100644 --- a/internal/codegen/parameter.go +++ b/internal/codegen/parameter.go @@ -93,12 +93,14 @@ type CodegenParameter struct { IsCollectionFormatMulti bool `json:"isCollectionFormatMulti"` // Nested - ComposedModels []string `json:"composedModels"` // member models of a union/intersection type, for imports - Items *CodegenProperty `json:"items"` - AdditionalProperties *CodegenProperty `json:"additionalProperties"` - Vars []*CodegenProperty `json:"vars"` - RequiredVars []*CodegenProperty `json:"requiredVars"` - MostInnerItems *CodegenProperty `json:"mostInnerItems"` + ComposedModels []string `json:"composedModels"` // member models of a union/intersection type, for imports + Items *CodegenProperty `json:"items"` + AdditionalProperties *CodegenProperty `json:"additionalProperties"` + Vars []*CodegenProperty `json:"vars"` + RequiredVars []*CodegenProperty `json:"requiredVars"` + MostInnerItems *CodegenProperty `json:"mostInnerItems"` + ComposedSchemas *CodegenComposedSchemas `json:"composedSchemas"` + Ref string `json:"ref"` // the $ref the parameter's schema is, as the spec writes it // Documentation Description string `json:"description"` diff --git a/internal/codegen/property.go b/internal/codegen/property.go index f9e8a8c..ab3b773 100644 --- a/internal/codegen/property.go +++ b/internal/codegen/property.go @@ -76,6 +76,9 @@ type CodegenProperty struct { IsInnerEnum bool `json:"isInnerEnum"` // Inline enum IsEnumRef bool `json:"isEnumRef"` // Reference to enum model + // Ref is the $ref the property is typed from, as the spec writes it. + Ref string `json:"ref"` + // Enum Enum []string `json:"enum"` EnumName string `json:"enumName"` diff --git a/internal/config/config.go b/internal/config/config.go index a67de51..78f584f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -153,6 +153,24 @@ func NewDartFetchConfig() *DartFetchConfig { } } +// GoFetchConfig holds configuration specific to the go-fetch generator. +// +// The go-fetch generator produces a Go client that imports only the +// standard library, in a single package. +type GoFetchConfig struct { + // PackageName is the Go package name of the generated files. + PackageName string `json:"packageName,omitempty"` + // WithGoMod writes a go.mod, making the output a module of its own. + WithGoMod bool `json:"withGoMod,omitempty"` + // ModuleName is the module path in go.mod (default: PackageName). + ModuleName string `json:"moduleName,omitempty"` +} + +// NewGoFetchConfig creates a new GoFetchConfig with default values. +func NewGoFetchConfig() *GoFetchConfig { + return &GoFetchConfig{PackageName: "openapi"} +} + // FileNamingType represents the file naming convention. type FileNamingType string diff --git a/internal/gen/gen.go b/internal/gen/gen.go index 276222a..68fe7d6 100644 --- a/internal/gen/gen.go +++ b/internal/gen/gen.go @@ -17,6 +17,7 @@ import ( "github.com/xseman/openapi-generator/internal/config" "github.com/xseman/openapi-generator/internal/generator" "github.com/xseman/openapi-generator/internal/generator/dart" + "github.com/xseman/openapi-generator/internal/generator/golang" "github.com/xseman/openapi-generator/internal/generator/typescript" "github.com/xseman/openapi-generator/internal/template" ) @@ -28,8 +29,8 @@ type Options struct { InputSpec string // OutputDir is the directory generated files are written to. OutputDir string - // GeneratorName selects the target generator: "typescript-fetch" or - // "dart-fetch". + // GeneratorName selects the target generator: "typescript-fetch", + // "dart-fetch" or "go-fetch". GeneratorName string // TemplateDir, if set, overrides the embedded templates with templates // loaded from this filesystem directory. @@ -68,8 +69,10 @@ func Generate(opts Options) error { return errors.New("generator-name is required (use -g flag or generatorName in config file)") } - if opts.GeneratorName != "typescript-fetch" && opts.GeneratorName != "dart-fetch" { - return fmt.Errorf("unsupported generator: %s (supported: 'typescript-fetch', 'dart-fetch')", opts.GeneratorName) + switch opts.GeneratorName { + case "typescript-fetch", "dart-fetch", "go-fetch": + default: + return fmt.Errorf("unsupported generator: %s (supported: 'typescript-fetch', 'dart-fetch', 'go-fetch')", opts.GeneratorName) } additionalProps := parseAdditionalProperties(opts.AdditionalProperties) @@ -82,7 +85,7 @@ func Generate(opts Options) error { AdditionalProperties: additionalProps, } - // Construct the generator (TypeScript or Dart) and keep both the + // Construct the generator (TypeScript, Dart or Go) and keep both the // abstract CodegenConfig (for parser/template plumbing) and the // concrete generator (for language-specific helpers like index-file // generation). @@ -90,6 +93,7 @@ func Generate(opts Options) error { cg generator.CodegenConfig tsGen *typescript.FetchGenerator dartGen *dart.FetchGenerator + goGen *golang.FetchGenerator ) switch opts.GeneratorName { @@ -110,6 +114,15 @@ func Generate(opts Options) error { dartGen.SetConfig(cfg) dartGen.DartConfig = dartConfig cg = dartGen + + case "go-fetch": + goConfig := config.NewGoFetchConfig() + applyGoFetchAdditionalProperties(goConfig, additionalProps) + + goGen = golang.NewFetchGenerator() + goGen.SetConfig(cfg) + goGen.GoConfig = goConfig + cg = goGen } if err := cg.ProcessOpts(); err != nil { @@ -127,6 +140,10 @@ func Generate(opts Options) error { case dartGen != nil: apiPackage = dartGen.ApiPackage modelPackage = dartGen.ModelPackage + + case goGen != nil: + apiPackage = goGen.ApiPackage + modelPackage = goGen.ModelPackage } spec, err := assembleSpec(cg, opts) @@ -156,6 +173,7 @@ func Generate(opts Options) error { cg: cg, tsGen: tsGen, dartGen: dartGen, + goGen: goGen, engine: engine, outputDir: opts.OutputDir, apiPackage: apiPackage, diff --git a/internal/gen/gofetch.go b/internal/gen/gofetch.go new file mode 100644 index 0000000..a459730 --- /dev/null +++ b/internal/gen/gofetch.go @@ -0,0 +1,53 @@ +package gen + +import ( + "fmt" + + "github.com/xseman/openapi-generator/internal/config" +) + +// applyGoFetchAdditionalProperties maps CLI / config additional-property +// values onto a GoFetchConfig. +func applyGoFetchAdditionalProperties(gc *config.GoFetchConfig, props map[string]any) { + if v, ok := props["packageName"].(string); ok { + gc.PackageName = v + } + + if v, ok := props["withGoMod"].(bool); ok { + gc.WithGoMod = v + } + + if v, ok := props["moduleName"].(string); ok { + gc.ModuleName = v + } +} + +// goDeclareParams marks the operations whose Params type this API file +// declares: an operation under several tags is rendered into each tag's +// file, and its Params type must be declared in only one, the first. +func goDeclareParams(opMaps []map[string]any, declared map[string]bool) { + for _, op := range opMaps { + ext, _ := op["vendorExtensions"].(map[string]any) + name, _ := ext["x-go-params"].(string) + + op["goDeclareParams"] = !declared[name] + declared[name] = true + } +} + +// PrintGoFetchConfigHelp prints the go-fetch additional config options to +// stdout (used by the "config-help" CLI command). +func PrintGoFetchConfigHelp() { + fmt.Println("CONFIG OPTIONS for go-fetch:") + fmt.Println() + fmt.Println(" packageName") + fmt.Println(" Go package name of the generated files. (Default: openapi)") + fmt.Println() + fmt.Println(" withGoMod") + fmt.Println(" Write a go.mod, making the output a module of its own.") + fmt.Println(" (Default: false)") + fmt.Println() + fmt.Println(" moduleName") + fmt.Println(" Module path in go.mod. (Default: packageName)") + fmt.Println() +} diff --git a/internal/gen/gofetch_test.go b/internal/gen/gofetch_test.go new file mode 100644 index 0000000..398b8ec --- /dev/null +++ b/internal/gen/gofetch_test.go @@ -0,0 +1,117 @@ +package gen + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// goTool returns the go binary, skipping the test under -short or without +// one: these tests compile generated code. +func goTool(t *testing.T) string { + t.Helper() + + if testing.Short() { + t.Skip("compiles generated Go code") + } + + bin, err := exec.LookPath("go") + if err != nil { + t.Skip("no go in PATH") + } + + return bin +} + +// runGo runs the go tool in dir and fails the test with its output when it +// fails or finds no package. +func runGo(t *testing.T, bin, dir string, args ...string) { + t.Helper() + + cmd := exec.Command(bin, args...) + cmd.Dir = dir + + cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=-mod=mod") + + out, err := cmd.CombinedOutput() + if err != nil || bytes.Contains(out, []byte("matched no packages")) { + t.Fatalf("go %s: %v\n%s", strings.Join(args, " "), err, out) + } +} + +func generateGoFetch(t *testing.T, spec, out string, props ...string) { + t.Helper() + + err := Generate(Options{ + InputSpec: spec, + OutputDir: out, + GeneratorName: "go-fetch", + TemplateDir: filepath.Join("..", "..", "templates", "go-fetch"), + AdditionalProperties: props, + SkipValidation: true, + }) + if err != nil { + t.Fatalf("Generate %s: %v", spec, err) + } +} + +// TestGoFetchCompiles generates every feature spec into a package of one +// module, vets it and runs the tests testdata/gofetch// holds, which +// exercise the client against httptest. +func TestGoFetchCompiles(t *testing.T) { + bin := goTool(t) + root := t.TempDir() + + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module gofetchcheck\n\ngo 1.24\n"), 0o600); err != nil { + t.Fatal(err) + } + + specs, err := filepath.Glob(filepath.Join("..", "..", "testdata", "specs", "feature", "*.yaml")) + if err != nil || len(specs) == 0 { + t.Fatalf("no feature specs: %v", err) + } + + for _, spec := range specs { + name := strings.TrimSuffix(filepath.Base(spec), ".yaml") + out := filepath.Join(root, name) + generateGoFetch(t, spec, out, "packageName="+strings.ReplaceAll(name, "-", "")) + + tests, _ := filepath.Glob(filepath.Join("..", "..", "testdata", "gofetch", name, "*_test.go")) + for _, test := range tests { + data, err := os.ReadFile(test) + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(out, filepath.Base(test)), data, 0o600); err != nil { + t.Fatal(err) + } + } + } + + // go test builds no package without a test file: vet compiles them all. + runGo(t, bin, root, "vet", "./...") + runGo(t, bin, root, "test", "./...") +} + +// TestGoFetchGoMod checks withGoMod on its own: a go.mod inside the module +// of TestGoFetchCompiles would cut its directory out of ./.... +func TestGoFetchGoMod(t *testing.T) { + bin := goTool(t) + out := t.TempDir() + generateGoFetch(t, filepath.Join("..", "..", "testdata", "specs", "feature", "request-body-json.yaml"), out, "withGoMod=true") + + data, err := os.ReadFile(filepath.Join(out, "go.mod")) + if err != nil { + t.Fatal(err) + } + + if want := "module openapi\n\ngo 1.24\n"; string(data) != want { + t.Errorf("go.mod = %q, want %q", data, want) + } + + runGo(t, bin, out, "vet", "./...") +} diff --git a/internal/gen/helpers.go b/internal/gen/helpers.go index f7b0ee3..ae4584b 100644 --- a/internal/gen/helpers.go +++ b/internal/gen/helpers.go @@ -11,11 +11,25 @@ import ( // parseAdditionalProperties converts a list of "key=value" strings (as // accepted via the CLI's --additional-properties flag) into a map, coercing // "true"/"false" values to booleans so downstream config binding can type -// assert on bool. +// assert on bool. One string may hold several pairs, "a=1,b=2", as upstream +// takes them; a piece without "=" belongs to the value before it. func parseAdditionalProperties(props []string) map[string]any { result := make(map[string]any) + var pairs []string + for _, prop := range props { + for i, piece := range strings.Split(prop, ",") { + if i > 0 && !strings.Contains(piece, "=") { + pairs[len(pairs)-1] += "," + piece + continue + } + + pairs = append(pairs, piece) + } + } + + for _, prop := range pairs { parts := strings.SplitN(prop, "=", 2) if len(parts) == 2 { key := strings.TrimSpace(parts[0]) diff --git a/internal/gen/helpers_test.go b/internal/gen/helpers_test.go index fc56ce4..a4600ab 100644 --- a/internal/gen/helpers_test.go +++ b/internal/gen/helpers_test.go @@ -41,6 +41,16 @@ func TestParseAdditionalProperties(t *testing.T) { props: []string{"query=a=b"}, want: map[string]any{"query": "a=b"}, }, + { + name: "one entry may hold several comma-separated pairs", + props: []string{"packageName=petshop,withGoMod=true", "npmDescription=cats, dogs,x=1"}, + want: map[string]any{ + "packageName": "petshop", + "withGoMod": true, + "npmDescription": "cats, dogs", + "x": "1", + }, + }, { name: "entries without '=' are silently ignored", props: []string{"noEquals", "valid=1"}, diff --git a/internal/gen/index.go b/internal/gen/index.go index b1effab..760d553 100644 --- a/internal/gen/index.go +++ b/internal/gen/index.go @@ -9,8 +9,14 @@ import ( ) // writeIndexFiles writes the per-folder barrel (index) file for models, and -// for APIs, when there is at least one model or operation respectively. +// for APIs, when there is at least one model or operation respectively; Go +// gets none. func (r *renderer) writeIndexFiles(models []*generator.CodegenModel, operationsByTag map[string][]*generator.CodegenOperation) ([]string, error) { + // A Go package is one directory: nothing to re-export. + if r.goGen != nil { + return nil, nil + } + indexExt := ".ts" if r.dartGen != nil { indexExt = ".dart" diff --git a/internal/gen/render.go b/internal/gen/render.go index 36d76cd..59dece6 100644 --- a/internal/gen/render.go +++ b/internal/gen/render.go @@ -11,6 +11,7 @@ import ( "github.com/xseman/openapi-generator/internal/generator" "github.com/xseman/openapi-generator/internal/generator/dart" + "github.com/xseman/openapi-generator/internal/generator/golang" "github.com/xseman/openapi-generator/internal/generator/typescript" "github.com/xseman/openapi-generator/internal/template" "github.com/xseman/openapi-generator/templates" @@ -112,6 +113,7 @@ type renderer struct { cg generator.CodegenConfig tsGen *typescript.FetchGenerator dartGen *dart.FetchGenerator + goGen *golang.FetchGenerator engine *template.Engine outputDir string apiPackage string @@ -283,6 +285,7 @@ func (r *renderer) renderAPIs(baseData map[string]any, operationsByTag map[strin var generatedFiles []string apiTemplates := r.cg.GetApiTemplateFiles() + goParams := make(map[string]bool) for _, tag := range sortedTags(operationsByTag) { ops := operationsByTag[tag] @@ -292,6 +295,10 @@ func (r *renderer) renderAPIs(baseData map[string]any, operationsByTag map[strin opMaps := template.ConvertSliceToMaps(ops) opMaps = template.PreprocessOperationData(opMaps) + if r.goGen != nil { + goDeclareParams(opMaps, goParams) + } + for tmplFile, ext := range apiTemplates { data := copyMap(baseData) data["classname"] = apiClassname diff --git a/internal/gen/spec.go b/internal/gen/spec.go index abbb86e..4df5206 100644 --- a/internal/gen/spec.go +++ b/internal/gen/spec.go @@ -2,9 +2,12 @@ package gen import ( "fmt" + "maps" "os" + "slices" "strings" + "github.com/getkin/kin-openapi/openapi3" "github.com/xseman/openapi-generator/internal/generator" "github.com/xseman/openapi-generator/internal/parser" ) @@ -63,6 +66,8 @@ func assembleSpec(cg generator.CodegenConfig, opts Options) (*specData, error) { return nil, fmt.Errorf("failed to get security schemes: %w", err) } + resolveSecurity(p.Doc, operationsByTag, securitySchemes) + if opts.Verbose { fmt.Printf("Found %d models\n", len(models)) @@ -74,9 +79,10 @@ func assembleSpec(cg generator.CodegenConfig, opts Options) (*specData, error) { fmt.Printf("Found %d operations in %d tags\n", opCount, len(operationsByTag)) } + // Tags in order: a generator may name types across operations (go-fetch). models = cg.PostProcessModels(models) - for tag, ops := range operationsByTag { - operationsByTag[tag] = cg.PostProcessOperations(ops) + for _, tag := range slices.Sorted(maps.Keys(operationsByTag)) { + operationsByTag[tag] = cg.PostProcessOperations(operationsByTag[tag]) } // A parameter typed as a model carries that model's read-only properties, so @@ -105,6 +111,57 @@ func assembleSpec(cg generator.CodegenConfig, opts Options) (*specData, error) { }, nil } +// resolveSecurity completes each operation's auth methods. The parser +// records only a scheme's name and the operation's scopes: the type, the +// key's name and location come from the scheme in components. An operation +// that declares no security takes the document's; "security: []" is none. +func resolveSecurity(doc *openapi3.T, operationsByTag map[string][]*generator.CodegenOperation, schemes []*generator.CodegenSecurity) { + byName := make(map[string]*generator.CodegenSecurity, len(schemes)) + for _, s := range schemes { + byName[s.Name] = s + } + + for _, ops := range operationsByTag { + for _, op := range ops { + if item := doc.Paths.Value(op.Path); item != nil { + if src := item.GetOperation(op.HttpMethod); src != nil && src.Security == nil { + op.AuthMethods = securityRequirements(doc.Security) + } + } + + for i, a := range op.AuthMethods { + if s := byName[a.Name]; s != nil { + full := *s + full.Scopes = a.Scopes + op.AuthMethods[i] = &full + } + } + + op.HasAuthMethods = len(op.AuthMethods) > 0 + } + } +} + +// securityRequirements lists the schemes of requirements with their scopes, +// names in sorted order within each requirement, as the parser does for an +// operation's own. +func securityRequirements(reqs openapi3.SecurityRequirements) []*generator.CodegenSecurity { + var out []*generator.CodegenSecurity + + for _, req := range reqs { + for _, name := range slices.Sorted(maps.Keys(req)) { + sec := &generator.CodegenSecurity{Name: name, Scopes: make([]map[string]any, len(req[name]))} + for i, scope := range req[name] { + sec.Scopes[i] = map[string]any{"scope": scope, "-last": i == len(req[name])-1} + } + + out = append(out, sec) + } + } + + return out +} + // loadSpec loads inputSpec into p, treating an http(s) prefix as a remote // URL and anything else as a local file path. func loadSpec(p *parser.Parser, inputSpec string) error { diff --git a/internal/gen/spec_test.go b/internal/gen/spec_test.go new file mode 100644 index 0000000..8536786 --- /dev/null +++ b/internal/gen/spec_test.go @@ -0,0 +1,69 @@ +package gen + +import ( + "testing" + + "github.com/xseman/openapi-generator/internal/generator/typescript" +) + +// TestResolveSecurity checks that an operation's auth methods carry their +// scheme's type and key, and that one without security of its own takes the +// document's while "security: []" opts out. +func TestResolveSecurity(t *testing.T) { + spec := writeSpec(t, "spec.yaml", `openapi: 3.0.0 +info: {title: t, version: "1"} +security: [{api_key: []}] +paths: + /inherits: + get: + operationId: inherits + responses: {"204": {description: ok}} + /none: + get: + operationId: none + security: [] + responses: {"204": {description: ok}} + /own: + get: + operationId: own + security: [{oauth: [read]}] + responses: {"204": {description: ok}} +components: + securitySchemes: + api_key: {type: apiKey, in: header, name: X-API-Key} + oauth: + type: oauth2 + flows: {implicit: {authorizationUrl: "https://example.com", scopes: {read: r, write: w}}} +`) + + data, err := assembleSpec(typescript.NewFetchGenerator(), Options{InputSpec: spec}) + if err != nil { + t.Fatal(err) + } + + ops := map[string]int{} + for _, op := range data.operationsByTag["default"] { + ops[op.OperationId]++ + + switch op.OperationId { + case "inherits": + if len(op.AuthMethods) != 1 || !op.AuthMethods[0].IsApiKey || !op.AuthMethods[0].IsKeyInHeader || op.AuthMethods[0].KeyParamName != "X-API-Key" { + t.Errorf("inherits auth = %+v, want the api_key header scheme", op.AuthMethods) + } + + case "none": + if len(op.AuthMethods) != 0 || op.HasAuthMethods { + t.Errorf("none auth = %+v, want none", op.AuthMethods) + } + + case "own": + if len(op.AuthMethods) != 1 || !op.AuthMethods[0].IsOAuth || len(op.AuthMethods[0].Scopes) != 1 || op.AuthMethods[0].Scopes[0]["scope"] != "read" { + t.Errorf("own auth = %+v, want oauth with the operation's scope read", op.AuthMethods) + } + } + } + + if len(ops) != 3 { + t.Errorf("operations = %v, want inherits, none, own", ops) + } +} diff --git a/internal/generator/golang/base.go b/internal/generator/golang/base.go new file mode 100644 index 0000000..002d66d --- /dev/null +++ b/internal/generator/golang/base.go @@ -0,0 +1,436 @@ +// Package golang provides the Go code generator; the name is golang because +// go is a keyword. +package golang + +import ( + "fmt" + "maps" + "strings" + "unicode" + + "github.com/xseman/openapi-generator/internal/codegen" + "github.com/xseman/openapi-generator/internal/config" + "github.com/xseman/openapi-generator/internal/generator" +) + +// TypeMapping maps OpenAPI types to the TypeScript-shaped DataType strings +// the parser builds on (Array, any, number). Go types are derived from a +// property's flags and tree instead, see GoType. +var TypeMapping = map[string]string{ + "array": "Array", + "boolean": "boolean", + "integer": "number", + "number": "number", + "object": "any", + "string": "string", + "AnyType": "any", +} + +// RuntimeNames are the identifiers runtime.go declares; a model taking one +// is renamed with a Model suffix. +var RuntimeNames = map[string]bool{ + "BasePath": true, "Doer": true, "RequestEditorFn": true, "Configuration": true, + "ResponseError": true, "Opt": true, "Some": true, "Null": true, "Ptr": true, + "Date": true, "DateTime": true, "File": true, "ServerSentEvent": true, +} + +// keywords are Go's reserved words, for the package name. +var keywords = map[string]bool{ + "break": true, "case": true, "chan": true, "const": true, "continue": true, + "default": true, "defer": true, "else": true, "fallthrough": true, "for": true, + "func": true, "go": true, "goto": true, "if": true, "import": true, + "interface": true, "map": true, "package": true, "range": true, "return": true, + "select": true, "struct": true, "switch": true, "type": true, "var": true, +} + +// initialisms are written in one case, as golint's commonInitialisms. +var initialisms = map[string]bool{ + "ACL": true, "API": true, "ASCII": true, "CPU": true, "CSS": true, "DNS": true, + "EOF": true, "GUID": true, "HTML": true, "HTTP": true, "HTTPS": true, "ID": true, + "IP": true, "JSON": true, "LHS": true, "QPS": true, "RAM": true, "RHS": true, + "RPC": true, "SLA": true, "SMTP": true, "SQL": true, "SSH": true, "TCP": true, + "TLS": true, "TTL": true, "UDP": true, "UI": true, "UID": true, "UUID": true, + "URI": true, "URL": true, "UTF8": true, "VM": true, "XML": true, "XMPP": true, + "XSRF": true, "XSS": true, +} + +// buildSuffixes are the file name suffixes the go tool reads as a build +// constraint or a test file (model_test.go, api_linux.go). +var buildSuffixes = map[string]bool{ + "test": true, "aix": true, "android": true, "darwin": true, "dragonfly": true, + "freebsd": true, "hurd": true, "illumos": true, "ios": true, "js": true, + "linux": true, "nacl": true, "netbsd": true, "openbsd": true, "plan9": true, + "solaris": true, "wasip1": true, "windows": true, "zos": true, "386": true, + "amd64": true, "arm": true, "arm64": true, "loong64": true, "mips": true, + "mips64": true, "mips64le": true, "mipsle": true, "ppc64": true, "ppc64le": true, + "riscv64": true, "s390x": true, "wasm": true, +} + +// BaseGenerator holds what a Go generator shares: the CodegenConfig plumbing +// and Go naming. +type BaseGenerator struct { + Config *config.GeneratorConfig + + TypeMapping map[string]string + ReservedWords map[string]bool + + ModelPackage string + ApiPackage string + + ApiTemplateFiles map[string]string + ModelTemplateFiles map[string]string + SupportingFiles []generator.SupportingFile + AdditionalProperties map[string]any +} + +// NewBaseGenerator creates a new Go base generator. +func NewBaseGenerator() *BaseGenerator { + return &BaseGenerator{ + TypeMapping: maps.Clone(TypeMapping), + ReservedWords: maps.Clone(RuntimeNames), + ApiTemplateFiles: make(map[string]string), + ModelTemplateFiles: make(map[string]string), + AdditionalProperties: make(map[string]any), + } +} + +// GetTypeMapping returns the type mapping. +func (g *BaseGenerator) GetTypeMapping() map[string]string { return g.TypeMapping } + +// GetReservedWords returns the reserved words. +func (g *BaseGenerator) GetReservedWords() map[string]bool { return g.ReservedWords } + +// GetLanguageSpecificPrimitives returns the primitive types. +func (g *BaseGenerator) GetLanguageSpecificPrimitives() map[string]bool { return nil } + +// GetImportMapping returns the import mapping. +func (g *BaseGenerator) GetImportMapping() map[string]string { return nil } + +// GetAdditionalProperties returns additional properties for templates. +func (g *BaseGenerator) GetAdditionalProperties() map[string]any { return g.AdditionalProperties } + +// IsReservedWord reports whether word is a name runtime.go declares. +func (g *BaseGenerator) IsReservedWord(word string) bool { return g.ReservedWords[word] } + +// EscapeReservedWord suffixes a reserved name with Model. +func (g *BaseGenerator) EscapeReservedWord(name string) string { + if g.IsReservedWord(name) { + return name + "Model" + } + + return name +} + +// GetSchemaType returns the parser's DataType string for an OpenAPI type. +func (g *BaseGenerator) GetSchemaType(schemaType, _ string) string { + return g.TypeMapping[schemaType] +} + +// GetTypeDeclaration returns the parser's DataType string for an OpenAPI type. +func (g *BaseGenerator) GetTypeDeclaration(schemaType, format string) string { + return g.GetSchemaType(schemaType, format) +} + +// ToModelName converts a schema name to an exported Go type name. +func (g *BaseGenerator) ToModelName(name string) string { + return g.EscapeReservedWord(exported(name, "Model")) +} + +// ToApiName converts a tag to the API type name, PetAPI. +func (g *BaseGenerator) ToApiName(name string) string { + return exported(name, "Default") + "API" +} + +// ToVarName converts a property or parameter name to an exported field name. +func (g *BaseGenerator) ToVarName(name string) string { return exported(name, "Var") } + +// ToParamName converts a parameter name to an exported field name. +func (g *BaseGenerator) ToParamName(name string) string { return g.ToVarName(name) } + +// SanitizeOperationId converts an operation ID to an exported method name. +func (g *BaseGenerator) SanitizeOperationId(operationId string) string { + return exported(operationId, "Op") +} + +// ToModelFilename returns the file name of a model, model_pet. +func (g *BaseGenerator) ToModelFilename(name string) string { + return fileName("model_" + Snake(name)) +} + +// ToApiFilename returns the file name of an API type, api_pet for PetAPI. +func (g *BaseGenerator) ToApiFilename(name string) string { + return fileName("api_" + Snake(strings.TrimSuffix(name, "API"))) +} + +// FromModel is unused: the parser builds models. +func (g *BaseGenerator) FromModel(name string, _ any) *codegen.CodegenModel { + return &codegen.CodegenModel{Name: name, Classname: g.ToModelName(name)} +} + +// FromOperation is unused: the parser builds operations. +func (g *BaseGenerator) FromOperation(path, httpMethod string, _ any) *codegen.CodegenOperation { + return &codegen.CodegenOperation{Path: path, HttpMethod: strings.ToUpper(httpMethod)} +} + +// FromProperty is unused: the parser builds properties. +func (g *BaseGenerator) FromProperty(name string, _ any, required bool) *codegen.CodegenProperty { + return &codegen.CodegenProperty{Name: g.ToVarName(name), BaseName: name, Required: required} +} + +// FromParameter is unused: the parser builds parameters. +func (g *BaseGenerator) FromParameter(_ any) *codegen.CodegenParameter { + return &codegen.CodegenParameter{} +} + +// FromResponse is unused: the parser builds responses. +func (g *BaseGenerator) FromResponse(code string, _ any) *codegen.CodegenResponse { + return &codegen.CodegenResponse{Code: code} +} + +// FromSecurityScheme is unused: the parser builds security schemes. +func (g *BaseGenerator) FromSecurityScheme(name string, _ any) *codegen.CodegenSecurity { + return &codegen.CodegenSecurity{Name: name} +} + +// GetSupportingFiles returns supporting files. +func (g *BaseGenerator) GetSupportingFiles() []generator.SupportingFile { return g.SupportingFiles } + +// GetApiTemplateFiles returns API template files. +func (g *BaseGenerator) GetApiTemplateFiles() map[string]string { return g.ApiTemplateFiles } + +// GetModelTemplateFiles returns model template files. +func (g *BaseGenerator) GetModelTemplateFiles() map[string]string { return g.ModelTemplateFiles } + +// GetConfig returns the generator config. +func (g *BaseGenerator) GetConfig() *config.GeneratorConfig { return g.Config } + +// SetConfig sets the generator config. +func (g *BaseGenerator) SetConfig(cfg *config.GeneratorConfig) { g.Config = cfg } + +// GoType is the Go type of a property, from its flags and its Items tree. +// named is the type declared for an inline enum or object at its leaf, or +// the enum or union model a $ref names; "" types an enum by its values, an +// object as a map and a union as raw JSON. +func GoType(p *codegen.CodegenProperty, named string) string { + switch { + case p == nil: + return "any" + case p.IsArray: + return "[]" + itemType(p.Items, named) + case p.IsMap: + return "map[string]" + itemType(p.Items, named) + case named != "" && (p.Ref != "" || p.IsEnum || len(p.Vars) > 0 || p.ComposedSchemas != nil): + return named + case isComposite(p.DataType): + // ponytail: an intersection, or a union not named (a union model's + // own inline member), stays raw JSON; the upgrade is a named type. + return "json.RawMessage" + case p.IsEnum: + return EnumBaseType(p.AllowableValues, p.IsBoolean) + case p.IsModel: + return p.DataType + } + + return scalarType(p.IsString, p.IsDate, p.IsDateTime, p.IsByteArray, p.IsInteger, p.IsLong, + p.IsNumber, p.IsFloat, p.IsBoolean, p.OpenApiType == "object") +} + +// itemType is the Go type of a slice element or map value: GoType, a +// pointer when the item is nullable and not nil-able already. +func itemType(p *codegen.CodegenProperty, named string) string { + t := GoType(p, named) + if p != nil && p.IsNullable && !isContainer(t) { + return "*" + t + } + + return t +} + +// GoParamType is the Go type of a parameter; a file is a File. named is +// the type declared for an inline object, as for GoType. +func GoParamType(p *codegen.CodegenParameter, named string) string { + switch { + case p.IsArray && p.IsFile: + return "[]File" + case p.IsArray: + return "[]" + itemType(p.Items, named) + case (len(p.Vars) > 0 || p.ComposedSchemas != nil || p.Ref != "") && named != "": + return named + case p.IsMap: + return "map[string]" + itemType(p.Items, named) + case isComposite(p.DataType): + return "json.RawMessage" + case p.IsFile || p.IsBinary: + return "File" + case p.IsEnum: + return EnumBaseType(p.AllowableValues, p.IsBoolean) + case p.IsModel: + return p.DataType + } + + return scalarType(p.IsString, p.IsDate, p.IsDateTime, p.IsByteArray, p.IsInteger, p.IsLong, + p.IsNumber, p.IsFloat, p.IsBoolean, false) +} + +func scalarType(isString, isDate, isDateTime, isBytes, isInteger, isLong, isNumber, isFloat, isBoolean, isObject bool) string { + switch { + case isDate: + return "Date" + case isDateTime: + return "DateTime" + case isBytes: + return "[]byte" + case isString: + return "string" + case isLong: + return "int64" + case isInteger: + return "int" + case isFloat: + return "float32" + case isNumber: + return "float64" + case isBoolean: + return "bool" + case isObject: + return "map[string]any" + } + + return "any" +} + +// EnumBaseType is the Go type an enum's values have: string when they are +// strings (isString on the enumVars), else bool, int when every value is +// whole, float64 otherwise; any when they are of more than one JSON type +// (pydantic's Literal["auto", 0]), which no Go constant type holds. A null +// among them makes the enum nullable, not mixed. +func EnumBaseType(allowable map[string]any, isBoolean bool) string { + values, _ := allowable["values"].([]any) + + kinds := map[string]bool{} + + for _, v := range values { + switch v.(type) { + case nil: + case string, bool: + kinds[fmt.Sprintf("%T", v)] = true + default: + kinds["number"] = true + } + } + + if len(kinds) > 1 { + return "any" + } + + vars, _ := allowable["enumVars"].([]map[string]any) + if len(vars) > 0 && vars[0]["isString"] == true { + return "string" + } + + if isBoolean { + return "bool" + } + + for _, v := range values { + if f, ok := v.(float64); ok && f != float64(int64(f)) { + return "float64" + } + } + + return "int" +} + +// isComposite reports whether the parser typed a value as a union or +// intersection of members ("A | B", "A & B"). +func isComposite(dataType string) bool { + return strings.Contains(dataType, " | ") || strings.Contains(dataType, " & ") +} + +// exported converts s to an exported Go identifier: PascalCase words with +// initialisms in upper case, prefix in front when it would not start with +// an upper-case letter (a digit, an empty name). +func exported(s, prefix string) string { + var b strings.Builder + + for _, w := range splitWords(s) { + if up := strings.ToUpper(w); initialisms[up] { + b.WriteString(up) + continue + } + + r := []rune(strings.ToLower(w)) + r[0] = unicode.ToUpper(r[0]) + b.WriteString(string(r)) + } + + name := b.String() + if r := []rune(name); len(r) == 0 || !unicode.IsUpper(r[0]) { + name = prefix + name + } + + return name +} + +// Snake converts s to snake_case, keeping an initialism one word. +func Snake(s string) string { + words := splitWords(s) + for i, w := range words { + words[i] = strings.ToLower(w) + } + + return strings.Join(words, "_") +} + +// fileName suffixes a name the go tool would read as a build constraint +// or a test file with an underscore. +func fileName(name string) string { + if i := strings.LastIndexByte(name, '_'); i >= 0 && buildSuffixes[name[i+1:]] { + return name + "_" + } + + return name +} + +// splitWords splits s on what is neither letter nor digit, before an upper +// case letter that follows a lower-case one or a digit, and before the last +// letter of an upper-case run that a lower-case letter follows (URLPath -> +// URL, Path). +func splitWords(s string) []string { + var ( + words []string + cur []rune + ) + + flush := func() { + if len(cur) > 0 { + words = append(words, string(cur)) + cur = nil + } + } + + for _, r := range s { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + flush() + continue + } + + if n := len(cur); n > 0 { + prev := cur[n-1] + + switch { + case unicode.IsUpper(r) && (unicode.IsLower(prev) || unicode.IsDigit(prev)): + flush() + case unicode.IsLower(r) && unicode.IsUpper(prev) && n > 1 && unicode.IsUpper(cur[n-2]): + words = append(words, string(cur[:n-1])) + cur = []rune{prev} + } + } + + cur = append(cur, r) + } + + flush() + + return words +} diff --git a/internal/generator/golang/base_test.go b/internal/generator/golang/base_test.go new file mode 100644 index 0000000..00e4a47 --- /dev/null +++ b/internal/generator/golang/base_test.go @@ -0,0 +1,117 @@ +package golang + +import ( + "testing" + + "github.com/xseman/openapi-generator/internal/codegen" +) + +func TestNames(t *testing.T) { + g := NewBaseGenerator() + + tests := []struct { + got, want string + }{ + {g.ToModelName("pet_id"), "PetID"}, + {g.ToModelName("HTTPGetAction"), "HTTPGetAction"}, + {g.ToModelName("io.k8s.api.v1.Pod"), "IoK8sAPIV1Pod"}, + {g.ToModelName("PET_STATUS"), "PetStatus"}, + {g.ToModelName("File"), "FileModel"}, + {g.ToModelName("Configuration"), "ConfigurationModel"}, + {g.ToModelName("2fa"), "Model2fa"}, + {g.ToVarName("first-name"), "FirstName"}, + {g.ToVarName("last.name"), "LastName"}, + {g.ToVarName("123code"), "Var123code"}, + {g.ToVarName("@type"), "Type"}, + {g.ToVarName("has space"), "HasSpace"}, + {g.ToVarName("a+b"), "AB"}, + {g.ToVarName("_leadingUnderscore"), "LeadingUnderscore"}, + {g.ToVarName("trailingUnderscore_"), "TrailingUnderscore"}, + {g.ToVarName("userUrl"), "UserURL"}, + {g.ToVarName("URLPath"), "URLPath"}, + {g.ToVarName(""), "Var"}, + {g.SanitizeOperationId("findPetById"), "FindPetByID"}, + {g.ToApiName("pet store"), "PetStoreAPI"}, + {g.ToApiFilename("PetStoreAPI"), "api_pet_store"}, + {g.ToApiFilename("LinuxAPI"), "api_linux_"}, + {g.ToModelFilename("HTTPServer"), "model_http_server"}, + {g.ToModelFilename("Test"), "model_test_"}, + {packageName("Pet-Store"), "petstore"}, + {packageName("2pets"), "pets"}, + {packageName("go"), "goapi"}, + {packageName("type"), "typeapi"}, + {packageName("_"), "openapi"}, + } + + for _, tt := range tests { + if tt.got != tt.want { + t.Errorf("got %q, want %q", tt.got, tt.want) + } + } +} + +func TestGoType(t *testing.T) { + str := &codegen.CodegenProperty{IsString: true} + pet := &codegen.CodegenProperty{IsModel: true, DataType: "Pet"} + + tests := []struct { + prop *codegen.CodegenProperty + want string + }{ + {str, "string"}, + {&codegen.CodegenProperty{IsString: true, IsUuid: true}, "string"}, + {&codegen.CodegenProperty{IsString: true, IsDate: true}, "Date"}, + {&codegen.CodegenProperty{IsString: true, IsDateTime: true}, "DateTime"}, + {&codegen.CodegenProperty{IsString: true, IsByteArray: true}, "[]byte"}, + {&codegen.CodegenProperty{IsString: true, IsBinary: true}, "string"}, + {&codegen.CodegenProperty{IsInteger: true}, "int"}, + {&codegen.CodegenProperty{IsInteger: true, IsLong: true}, "int64"}, + {&codegen.CodegenProperty{IsNumber: true}, "float64"}, + {&codegen.CodegenProperty{IsNumber: true, IsFloat: true}, "float32"}, + {&codegen.CodegenProperty{IsNumber: true, IsDouble: true}, "float64"}, + {&codegen.CodegenProperty{IsBoolean: true}, "bool"}, + {&codegen.CodegenProperty{IsArray: true, Items: str}, "[]string"}, + {&codegen.CodegenProperty{IsArray: true, UniqueItems: true, Items: pet}, "[]Pet"}, + {&codegen.CodegenProperty{IsArray: true, Items: &codegen.CodegenProperty{IsArray: true, Items: pet}}, "[][]Pet"}, + {&codegen.CodegenProperty{IsMap: true, Items: pet}, "map[string]Pet"}, + {&codegen.CodegenProperty{IsMap: true}, "map[string]any"}, + {&codegen.CodegenProperty{OpenApiType: "object", IsFreeFormObject: true}, "map[string]any"}, + {&codegen.CodegenProperty{IsAnyType: true}, "any"}, + {pet, "Pet"}, + {&codegen.CodegenProperty{DataType: "Cat | Dog", IsFreeFormObject: true}, "json.RawMessage"}, + {&codegen.CodegenProperty{IsArray: true, Items: &codegen.CodegenProperty{DataType: "Cat | Dog"}}, "[]json.RawMessage"}, + } + + for _, tt := range tests { + if got := GoType(tt.prop, ""); got != tt.want { + t.Errorf("GoType(%+v) = %q, want %q", tt.prop, got, tt.want) + } + } + + enum := &codegen.CodegenProperty{IsArray: true, IsEnum: true, Items: &codegen.CodegenProperty{IsEnum: true, IsString: true}} + if got := GoType(enum, "PetTagsEnum"); got != "[]PetTagsEnum" { + t.Errorf("GoType(array of enum) = %q, want []PetTagsEnum", got) + } +} + +func TestEnumBaseType(t *testing.T) { + str := []map[string]any{{"isString": true}} + + tests := []struct { + values []any + vars []map[string]any + want string + }{ + {[]any{"a", "b"}, str, "string"}, + {[]any{"a", nil}, str, "string"}, + {[]any{1, 2, nil}, nil, "int"}, + {[]any{"auto", 0}, str, "any"}, + {[]any{1, true}, nil, "any"}, + } + + for _, tt := range tests { + if got := EnumBaseType(map[string]any{"values": tt.values, "enumVars": tt.vars}, false); got != tt.want { + t.Errorf("EnumBaseType(%v) = %q, want %q", tt.values, got, tt.want) + } + } +} diff --git a/internal/generator/golang/fetch.go b/internal/generator/golang/fetch.go new file mode 100644 index 0000000..d9dea6c --- /dev/null +++ b/internal/generator/golang/fetch.go @@ -0,0 +1,1319 @@ +package golang + +import ( + "fmt" + "maps" + "reflect" + "slices" + "strconv" + "strings" + + "github.com/xseman/openapi-generator/internal/codegen" + "github.com/xseman/openapi-generator/internal/config" + "github.com/xseman/openapi-generator/internal/generator" +) + +// FetchGenerator implements the go-fetch generator: a client in the shape of +// typescript-fetch (an API type per tag, a Request, Raw and typed method per +// operation) that imports only the standard library. +// +// Templates read what they render from vendorExtensions keys x-go-*, which +// PostProcessModels and PostProcessOperations fill in, and the api template +// also from goDeclareParams, which the pipeline sets. +type FetchGenerator struct { + *BaseGenerator + + GoConfig *config.GoFetchConfig + + // containers are the models declared as a slice, map or any alias: like + // a slice, a field of one needs no pointer to be optional. + containers map[string]bool + + // types are the type names declared so far, inline ones included; + // opTypes the inline type names of operations, by operation and base. + types map[string]bool + opTypes map[string]string + + // refs are the enum and union models by schema name, for a property + // that refers to one: the parser types it as the enum or union inline. + refs map[string]string + + // enums are the enum models by class: a parameter that refers to one + // takes its type; one that refers to a union stays raw JSON, which the + // runtime sends as a scalar. + enums map[string]bool + + // nullable are the nullable models by class: a result that refers to + // one is a pointer, nil for a null body. + nullable map[string]bool + + // required are the quoted required properties of each struct model, which + // a union's As method checks a value for; pinned its properties an enum + // of one value fixes (see pinned). + required map[string]string + pinned map[string][]map[string]any +} + +// NewFetchGenerator creates a new go-fetch generator. +func NewFetchGenerator() *FetchGenerator { + g := &FetchGenerator{BaseGenerator: NewBaseGenerator(), containers: map[string]bool{}, types: maps.Clone(RuntimeNames), opTypes: map[string]string{}, refs: map[string]string{}, enums: map[string]bool{}, nullable: map[string]bool{}, required: map[string]string{}, pinned: map[string][]map[string]any{}} + g.ApiTemplateFiles["api.mustache"] = ".go" + g.ModelTemplateFiles["model.mustache"] = ".go" + + return g +} + +// GetName returns the generator name. +func (g *FetchGenerator) GetName() string { return "go-fetch" } + +// GetTag returns the generator type. +func (g *FetchGenerator) GetTag() generator.GeneratorType { return generator.GeneratorTypeClient } + +// GetHelp returns the help text. +func (g *FetchGenerator) GetHelp() string { + return "Generates a Go client library that imports only the standard library." +} + +// ProcessOpts processes options and initializes the generator. +func (g *FetchGenerator) ProcessOpts() error { + cfg := g.GoConfig + if cfg == nil { + cfg = config.NewGoFetchConfig() + } + + pkg := packageName(cfg.PackageName) + + module := cfg.ModuleName + if module == "" { + module = pkg + } + + g.ApiPackage, g.ModelPackage = "", "" + + g.AdditionalProperties["packageName"] = pkg + g.AdditionalProperties["moduleName"] = module + g.AdditionalProperties["withGoMod"] = cfg.WithGoMod + + // ponytail: runtime.go's BasePath is servers[0].url as written, server + // variables are not substituted; a caller with a templated URL sets + // Configuration.BasePath. The upgrade is filling in their defaults. + g.SupportingFiles = append(g.SupportingFiles, + generator.NewSupportingFile("runtime.mustache", "", "runtime.go"), + generator.NewSupportingFile("README.mustache", "", "README.md"), + ) + if cfg.WithGoMod { + g.SupportingFiles = append(g.SupportingFiles, generator.NewSupportingFile("gomod.mustache", "", "go.mod")) + } + + return nil +} + +// packageName keeps the lower-case letters and digits of name, "openapi" +// when none are left; a keyword gets an api suffix. +func packageName(name string) string { + var b strings.Builder + + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9' && b.Len() > 0) { + b.WriteRune(r) + } + } + + switch pkg := b.String(); { + case pkg == "": + return "openapi" + case keywords[pkg]: + return pkg + "api" + default: + return pkg + } +} + +// PostProcessModels types every model for Go: struct fields with allOf +// flattened, enums, aliases and union wrappers. +func (g *FetchGenerator) PostProcessModels(models []*codegen.CodegenModel) []*codegen.CodegenModel { + bySchema := make(map[string]*codegen.CodegenModel, len(models)) + for _, m := range models { + bySchema[m.SchemaName] = m + } + + for _, m := range models { + if len(m.AllOf) > 0 { + flattenAllOf(m, bySchema) + } + } + + kinds := make(map[string]string, len(models)) + + for _, m := range models { + kind := modelKind(m) + kinds[m.Classname] = kind + g.types[m.Classname] = true + + if kind == "enum" || kind == "union" { + g.refs[m.SchemaName] = m.Classname + } + + g.enums[m.Classname] = kind == "enum" + g.nullable[m.Classname] = m.IsNullable + + g.required[m.Classname] = requiredKeys(m.AllVars) + g.pinned[m.Classname] = pinned(m.Classname, m.AllVars) + + if kind == "alias" { + if t := aliasType(m); isContainer(t) { + g.containers[m.Classname] = true + } + } + } + + fields := make(map[string][]map[string]any) + + for _, m := range models { + ext := clone(m.VendorExtensions) + ext["x-go-doc"] = docLines(m.Description) + ext["x-go-enums"] = []map[string]any{} + ext["x-go-"+kinds[m.Classname]] = true + + switch kinds[m.Classname] { + case "struct": + fields[m.Classname] = g.structFields(m, ext, g.modelTypeName) + fields[m.Classname] = extraField(m, fields[m.Classname], ext) + ext["x-go-fields"] = fields[m.Classname] + + inline, _ := ext["x-go-inline"].([]map[string]any) + for _, decl := range inline { + if decl["union"] == true { + continue + } + + name, _ := decl["name"].(string) + fields[name], _ = decl["fields"].([]map[string]any) + kinds[name] = "struct" + } + + case "enum": + ext["x-go-enums"] = []map[string]any{enumDecl(m.Classname, EnumBaseType(m.AllowableValues, m.IsBoolean), m.AllowableValues, nil)} + case "alias": + ext["x-go-alias-type"] = aliasType(m) + case "union": + ext["x-go-doc"] = docLines(m.Description, m.Classname+" holds one of its variants as raw JSON:\nthe As methods decode it, the From methods set it.") + g.unionExtensions(m, ext, bySchema) + } + + m.VendorExtensions = ext + } + + breakCycles(fields, kinds) + + return models +} + +// modelKind is how a model is declared: union (oneOf/anyOf), enum, struct +// (properties or allOf) or alias of a slice, map or scalar type. +// +// ponytail: a discriminator without oneOf (allOf inheritance) makes no +// union: the parent stays a plain struct; the upgrade is a union wrapper for +// the parent too. +func modelKind(m *codegen.CodegenModel) string { + switch { + case len(m.OneOf) > 0 || len(m.AnyOf) > 0: + return "union" + case m.IsEnum && EnumBaseType(m.AllowableValues, m.IsBoolean) != "any": + return "enum" + case len(m.AllVars) > 0 || len(m.AllOf) > 0: + return "struct" + } + + return "alias" +} + +// aliasType is the type a model without properties stands for. +func aliasType(m *codegen.CodegenModel) string { + switch { + case m.IsArray: + return "[]" + itemType(m.Items, "") + case m.AdditionalProperties != nil: + return "map[string]" + itemType(m.AdditionalProperties, "") + case m.IsString: + return "string" + case m.IsInteger: + return "int" + case m.IsNumber: + return "float64" + case m.IsBoolean: + return "bool" + case m.DataType == "any" || m.IsAdditionalPropertiesTrue: + return "map[string]any" + } + + return "any" +} + +// extraField adds to a struct that takes additionalProperties next to its +// properties a map field for the keys it does not declare, and describes in +// ext the (Un)MarshalJSON that moves them in and out of it. +func extraField(m *codegen.CodegenModel, fields []map[string]any, ext map[string]any) []map[string]any { + if !m.IsAdditionalPropertiesTrue && m.AdditionalProperties == nil { + return fields + } + + value := "any" + if m.AdditionalProperties != nil { + value = itemType(m.AdditionalProperties, "") + } + + taken := make(map[string]int) + + declared := make([]string, 0, len(m.AllVars)) + for _, v := range m.AllVars { + declared = append(declared, strconv.Quote(v.BaseName)) + } + + for _, f := range fields { + name, _ := f["name"].(string) + taken[name]++ + } + + name := uniqueName(taken, "AdditionalProperties") + fields = append(fields, map[string]any{ + "name": name, "type": "map[string]" + value, "tag": "`json:\"-\"`", + "doc": []string{"// " + name + " holds the keys the schema does not declare."}, + }) + ext["x-go-extra"] = map[string]any{"field": name, "value": value, "declared": strings.Join(declared, ", ")} + + return fields +} + +// isContainer reports whether a type is nil-able as it is (slice, map, any, +// raw JSON), so an optional field of it needs no pointer. +func isContainer(t string) bool { + return strings.HasPrefix(t, "[]") || strings.HasPrefix(t, "map[") || + t == "any" || t == "json.RawMessage" || t == "io.Reader" +} + +// flattenAllOf gives a model built with allOf its ancestors' properties: the +// parser records the members only by name. Ancestors come first, in allOf +// order; a property declared again nearer the model replaces the inherited +// one in place, carrying its own required. +func flattenAllOf(m *codegen.CodegenModel, bySchema map[string]*codegen.CodegenModel) { + vars := allVars(m, bySchema, map[string]bool{m.SchemaName: true}) + + m.AllVars = vars + m.RequiredVars = slices.DeleteFunc(slices.Clone(vars), func(v *codegen.CodegenProperty) bool { return !v.Required }) + m.OptionalVars = slices.DeleteFunc(slices.Clone(vars), func(v *codegen.CodegenProperty) bool { return v.Required }) + m.ReadOnlyVars = slices.DeleteFunc(slices.Clone(vars), func(v *codegen.CodegenProperty) bool { return !v.IsReadOnly }) +} + +func allVars(m *codegen.CodegenModel, bySchema map[string]*codegen.CodegenModel, visiting map[string]bool) []*codegen.CodegenProperty { + var out []*codegen.CodegenProperty + + at := make(map[string]int) + add := func(v *codegen.CodegenProperty) { + if i, ok := at[v.BaseName]; ok { + out[i] = v + return + } + + at[v.BaseName] = len(out) + out = append(out, v) + } + + for _, name := range m.AllOf { + parent := bySchema[name] + if parent == nil || visiting[name] { + continue + } + + visiting[name] = true + + for _, v := range allVars(parent, bySchema, visiting) { + add(v) + } + + delete(visiting, name) + } + + for _, v := range m.Vars { + add(v) + } + + return out +} + +// structFields types the properties of a struct model and declares its +// inline enums and objects in ext, named by typeName. +func (g *FetchGenerator) structFields(m *codegen.CodegenModel, ext map[string]any, typeName func(string) string) []map[string]any { + var ( + fields []map[string]any + enums []map[string]any + inline []map[string]any + ) + + names := make(map[string]int) + enumNames := make(map[string]string) + + for _, v := range m.AllVars { + named := "" + + what := "the " + v.BaseName + " property of " + m.Classname + if v.IsArray || v.IsMap { + what = "an item of " + what + } + + if obj := inlineObject(v); obj != nil { + named = typeName(m.Classname + v.Name) + decls, nested := g.inlineStructs(named, what, obj.Description, obj.Vars, typeName) + inline = append(inline, decls...) + enums = append(enums, nested...) + } + + if u := inlineUnion(v); u != nil { + named = typeName(m.Classname + v.Name) + decls, nested := g.unionDecl(named, what, u.Description, u.ComposedSchemas, typeName) + inline = append(inline, decls...) + enums = append(enums, nested...) + } + + if ref := g.refModel(v.Ref); ref != "" { + named = ref + } else if v.IsEnum && EnumBaseType(v.AllowableValues, v.IsBoolean) != "any" { + base := m.Classname + exported(v.EnumName, "") + + named = enumNames[base] + if named == "" { + named = typeName(base) + enumNames[base] = named + + elem := v + for elem.IsArray && elem.Items != nil { + elem = elem.Items + } + + enums = append(enums, enumDecl(named, EnumBaseType(v.AllowableValues, elem.IsBoolean), v.AllowableValues, v)) + } + } + + t := GoType(v, named) + ref := "" + tag := v.BaseName + + optional := !v.Required || v.IsReadOnly + container := isContainer(t) || g.containers[t] + + switch { + case optional && v.IsNullable: + if !container { + ref = t + } + + t = "Opt[" + t + "]" + tag += ",omitzero" + + case optional && container: + // nil is absent; an empty slice or map is still sent, as [] / {}. + tag += ",omitzero" + case optional: + t = "*" + t + tag += ",omitempty" + + case v.IsNullable && !container: + t = "*" + t + case !container: + ref = t + } + + // ponytail: a required slice or map left nil encodes as null, which a + // server may refuse; the upgrade is a MarshalJSON that sends [] / {}. + + fields = append(fields, map[string]any{ + "name": uniqueName(names, v.Name), + "type": t, + "tag": jsonTag(tag), + "doc": docLines(v.Description), + "ref": ref, + }) + } + + ext["x-go-enums"] = enums + ext["x-go-inline"] = inline + + return fields +} + +// inlineObject is the inline object a property holds, itself or as the +// element of its slices and maps; nil when there is none. +func inlineObject(p *codegen.CodegenProperty) *codegen.CodegenProperty { + for p != nil && (p.IsArray || p.IsMap) { + p = p.Items + } + + if p == nil || len(p.Vars) == 0 { + return nil + } + + return p +} + +// refModel is the enum or union model a $ref names, "" for none. +func (g *FetchGenerator) refModel(ref string) string { + if ref == "" { + return "" + } + + return g.refs[ref[strings.LastIndexByte(ref, '/')+1:]] +} + +// inlineUnion is the inline oneOf/anyOf a property holds, itself or as the +// element of its slices and maps; nil when there is none. +func inlineUnion(p *codegen.CodegenProperty) *codegen.CodegenProperty { + for p != nil && (p.IsArray || p.IsMap) { + p = p.Items + } + + if p == nil || p.ComposedSchemas == nil { + return nil + } + + return p +} + +// unionDecl declares the union wrapper name for an inline oneOf/anyOf, the +// model template's union with a variant per member and the discriminator +// its variants imply. +func (g *FetchGenerator) unionDecl(name, what, description string, members *codegen.CodegenComposedSchemas, typeName func(string) string) (decls, enums []map[string]any) { + var types []string + + for i, member := range slices.Concat(members.OneOf, members.AnyOf) { + named := g.refModel(member.Ref) + + // An inline object member is a struct of its own, named as upstream + // names it: OneOf, OneOf1, ... + if obj := inlineObject(member); obj != nil { + kind := "OneOf" + if i >= len(members.OneOf) { + kind, i = "AnyOf", i-len(members.OneOf) + } + + if i > 0 { + kind += strconv.Itoa(i) + } + + named = typeName(name + kind) + structs, nested := g.inlineStructs(named, "a variant of "+name, obj.Description, obj.Vars, typeName) + decls = append(decls, structs...) + enums = append(enums, nested...) + + g.required[named] = requiredKeys(obj.Vars) + g.pinned[named] = pinned(named, obj.Vars) + } + + types = append(types, GoType(member, named)) + } + + variants, _ := g.variants(types) + + union := map[string]any{ + "name": name, "union": true, "classname": name, + "vendorExtensions": map[string]any{ + "x-go-doc": docLines(name+" is "+what+", one of its variants as raw JSON:\nthe As methods decode it, the From methods set it.", description), + "x-go-variants": variants, + "x-go-discriminator": impliedDiscriminator(variants), + }, + } + + return append([]map[string]any{union}, decls...), enums +} + +// requiredKeys is the quoted names of the required vars, comma-separated. +func requiredKeys(vars []*codegen.CodegenProperty) string { + var required []string + + for _, v := range vars { + if v.Required { + required = append(required, strconv.Quote(v.BaseName)) + } + } + + return strings.Join(required, ", ") +} + +// pinned lists the properties of a struct an enum of one value fixes, a +// pydantic Literal or an OpenAPI 3.1 const: a union's From sets them, its +// As refuses a value with another. Fields are named as structFields does. +func pinned(class string, vars []*codegen.CodegenProperty) []map[string]any { + var out []map[string]any + + names := make(map[string]int) + + for _, v := range vars { + field := uniqueName(names, v.Name) + + values, _ := v.AllowableValues["enumVars"].([]map[string]any) + if !v.IsEnum || len(values) != 1 || !v.Required || v.IsNullable || v.IsReadOnly || v.IsArray || v.IsMap { + continue + } + + decl := enumDecl("", EnumBaseType(v.AllowableValues, v.IsBoolean), v.AllowableValues, nil) + literal, _ := decl["values"].([]map[string]any)[0]["literal"].(string) + + out = append(out, map[string]any{ + "field": field, "key": v.BaseName, "literal": literal, + "mismatch": strconv.Quote("not a " + class + ": " + strings.ReplaceAll(v.BaseName, "%", "%%") + " is %v"), + }) + } + + return out +} + +// impliedDiscriminator is the discriminator of a union whose every variant +// pins one string property to a value of its own, false when there is none. +func impliedDiscriminator(variants []map[string]any) any { + if len(variants) == 0 { + return false + } + + first, _ := variants[0]["pinned"].([]map[string]any) + + for _, candidate := range first { + key := candidate["key"] + values := make(map[string]bool) + + var mappings []map[string]any + + for _, variant := range variants { + pins, _ := variant["pinned"].([]map[string]any) + + i := slices.IndexFunc(pins, func(p map[string]any) bool { return p["key"] == key }) + if i < 0 { + break + } + + literal, _ := pins[i]["literal"].(string) + if values[literal] || !strings.HasPrefix(literal, `"`) { + break + } + + values[literal] = true + mappings = append(mappings, map[string]any{"value": literal, "type": variant["type"]}) + } + + if len(mappings) == len(variants) { + name, _ := key.(string) + + return map[string]any{"name": name, "quoted": strconv.Quote(name), "tag": jsonTag(name), "mappings": mappings} + } + } + + return false +} + +// inlineStructs declares the struct name for an inline object's properties, +// and the inline enums and objects those hold. what says where the object +// sits, for the doc comment. +func (g *FetchGenerator) inlineStructs(name, what, description string, vars []*codegen.CodegenProperty, typeName func(string) string) (decls, enums []map[string]any) { + ext := map[string]any{} + fields := g.structFields(&codegen.CodegenModel{Classname: name, AllVars: vars}, ext, typeName) + + nested, _ := ext["x-go-inline"].([]map[string]any) + enums, _ = ext["x-go-enums"].([]map[string]any) + + decl := map[string]any{"name": name, "union": false, "doc": docLines(name+" is "+what+".", description), "fields": fields} + + return append([]map[string]any{decl}, nested...), enums +} + +// modelTypeName names an inline type: base, with a count when a type has +// the name already (Foo + BarBaz and FooBar + Baz run together). +func (g *FetchGenerator) modelTypeName(base string) string { + name := base + for i := 2; g.types[name]; i++ { + name = base + strconv.Itoa(i) + } + + g.types[name] = true + + return name +} + +// opTypeNamer names the inline types of operation op as modelTypeName does, +// the same name each time it is asked again: an operation under several tags +// is processed once per tag. +func (g *FetchGenerator) opTypeNamer(op string) func(string) string { + return func(base string) string { + key := op + "\x00" + base + if name, ok := g.opTypes[key]; ok { + return name + } + + name := g.modelTypeName(base) + g.opTypes[key] = name + + return name + } +} + +// uniqueName suffixes a name already taken in a struct with a count. +func uniqueName(taken map[string]int, name string) string { + taken[name]++ + if n := taken[name]; n > 1 { + for taken[name+strconv.Itoa(n)] > 0 { + n++ + } + + name += strconv.Itoa(n) + taken[name]++ + } + + return name +} + +// jsonTag is the struct tag for a JSON name and options, as a raw string +// unless the name needs escaping. +func jsonTag(nameOpts string) string { + tag := "json:" + strconv.Quote(nameOpts) + if strings.ContainsAny(tag, "`") { + return strconv.Quote(tag) + } + + return "`" + tag + "`" +} + +// enumDecl is a named enum type with one constant per value. owner is the +// property an inline enum belongs to, nil for an enum model. +func enumDecl(name, base string, allowable map[string]any, owner *codegen.CodegenProperty) map[string]any { + vars, _ := allowable["enumVars"].([]map[string]any) + unescape := strings.NewReplacer(`\\`, `\`, `\'`, `'`) + + values := make([]map[string]any, 0, len(vars)) + for _, v := range vars { + literal, _ := v["value"].(string) + if base == "string" { + literal = strconv.Quote(unescape.Replace(literal)) + } + + member, _ := v["name"].(string) + values = append(values, map[string]any{ + "name": name + exported(member, ""), + "type": name, + "literal": literal, + }) + } + + var doc []string + if owner != nil { + doc = []string{"// " + name + " is a value of " + owner.BaseName + "."} + } + + return map[string]any{"name": name, "base": base, "values": values, "doc": doc} +} + +// breakCycles turns into a pointer each struct-typed field held by value +// (required, or in an Opt) that closes a cycle of such fields (A holds a B +// holds an A), which Go cannot lay out. Models are walked in name order, so +// the choice is stable. +func breakCycles(fields map[string][]map[string]any, kinds map[string]string) { + state := make(map[string]int) // 1 on the path, 2 done + + var visit func(name string) + + visit = func(name string) { + state[name] = 1 + + for _, f := range fields[name] { + ref, _ := f["ref"].(string) + if kinds[ref] != "struct" { + continue + } + + switch state[ref] { + case 1: + if t, _ := f["type"].(string); strings.HasPrefix(t, "Opt[") { + f["type"] = "Opt[*" + ref + "]" + } else { + f["type"] = "*" + ref + } + + case 0: + visit(ref) + } + } + + state[name] = 2 + } + + names := slices.Sorted(maps.Keys(fields)) + for _, name := range names { + if state[name] == 0 { + visit(name) + } + } +} + +// unionExtensions describes a oneOf/anyOf model's variants and, with a +// discriminator, how its values map to models. +func (g *FetchGenerator) unionExtensions(m *codegen.CodegenModel, ext map[string]any, bySchema map[string]*codegen.CodegenModel) { + var types []string + + types = append(types, m.OneOfModels...) + for _, item := range m.OneOfArrays { + types = append(types, "[]"+item) + } + + for _, p := range m.OneOfPrimitives { + types = append(types, GoType(p, "")) + } + + for _, name := range m.AnyOf { + types = append(types, g.ToModelName(name)) + } + + variants, seen := g.variants(types) + ext["x-go-variants"] = variants + + d := m.Discriminator + if d == nil { + ext["x-go-discriminator"] = impliedDiscriminator(variants) + return + } + + var mappings []map[string]any + + for _, mm := range d.MappedModels { + mappings = append(mappings, map[string]any{"value": strconv.Quote(mm.MappingName), "type": g.ToModelName(mm.ModelName)}) + } + + // Without a mapping a value names its schema, as OpenAPI defines. + if len(d.Mapping) == 0 { + implicit := make(map[string]string) + + for _, member := range bySchema { + if seen[member.Classname] { + implicit[member.SchemaName] = member.Classname + } + } + + for _, value := range slices.Sorted(maps.Keys(implicit)) { + mappings = append(mappings, map[string]any{"value": strconv.Quote(value), "type": implicit[value]}) + } + } + + ext["x-go-discriminator"] = map[string]any{ + "name": d.PropertyBaseName, + "quoted": strconv.Quote(d.PropertyBaseName), + "tag": jsonTag(d.PropertyBaseName), + "mappings": mappings, + } +} + +// variants describes a union's variant per type, in order, without repeats; +// seen holds the types. A model variant's required properties tell a value +// of it from the others: As refuses JSON that lacks one. +func (g *FetchGenerator) variants(types []string) (variants []map[string]any, seen map[string]bool) { + seen = make(map[string]bool) + + for _, t := range types { + if seen[t] { + continue + } + + seen[t] = true + variants = append(variants, map[string]any{"type": t, "method": variantName(t), "required": g.required[t], "pinned": g.pinned[t]}) + } + + return variants, seen +} + +// variantName names the AsX/FromX methods of a union variant after its +// type: Cat, CatList for []Cat, StringMap for map[string]string, Time. +func variantName(t string) string { + switch { + case strings.HasPrefix(t, "[]"): + return variantName(t[2:]) + "List" + case strings.HasPrefix(t, "map[string]"): + return variantName(t[len("map[string]"):]) + "Map" + } + + t = t[strings.LastIndexByte(t, '.')+1:] + + return strings.ToUpper(t[:1]) + t[1:] +} + +// docLines turns texts into // comment lines, a bare // between texts. +func docLines(texts ...string) []string { + lines := []string{} + + for _, text := range texts { + text = strings.TrimSpace(strings.ReplaceAll(text, "\r", "")) + if text == "" { + continue + } + + if len(lines) > 0 { + lines = append(lines, "//") + } + + for _, line := range strings.Split(text, "\n") { + if line = strings.TrimRight(line, " \t"); line == "" { + lines = append(lines, "//") + } else { + lines = append(lines, "// "+line) + } + } + } + + return lines +} + +// clone copies vendor extensions before they are written to: the parser +// shares the spec's own map between copies of an operation. +func clone(ext map[string]any) map[string]any { + if ext == nil { + return map[string]any{} + } + + return maps.Clone(ext) +} + +// PostProcessOperations names each operation's methods and Params type and +// types its parameters, body, responses and auth for the api template. +func (g *FetchGenerator) PostProcessOperations(ops []*codegen.CodegenOperation) []*codegen.CodegenOperation { + names := make(map[string]bool, len(ops)) + for _, op := range ops { + names[g.SanitizeOperationId(op.OperationId)] = true + } + + for _, op := range ops { + base := g.SanitizeOperationId(op.OperationId) + + // FooRaw and FooRequest are Foo's own methods. + name := base + for _, suffix := range []string{"Raw", "Request"} { + if s, ok := strings.CutSuffix(base, suffix); ok && names[s] { + name = base + "Method" + } + } + + op.Nickname = name + + ext := clone(op.VendorExtensions) + ext["x-go-name"] = name + ext["x-go-params"] = g.opTypeNamer(op.OperationId)(base + "Params") + ext["x-go-has-params"] = len(op.AllParams) > 0 + ext["x-go-doc"] = docLines(name+" sends "+op.HttpMethod+" "+op.Path+".", op.Summary, op.Notes) + ext["x-go-path"] = strconv.Quote(op.Path) + ext["x-go-method"] = strconv.Quote(op.HttpMethod) + ext["x-go-auth"] = authEntries(op) + + ext["x-go-inline"], ext["x-go-inline-enums"] = []map[string]any{}, []map[string]any{} + g.paramExtensions(op, ext, base) + g.responseExtensions(op, ext, base) + + op.VendorExtensions = ext + } + + return ops +} + +// paramExtensions declares the Params fields and, per location, how each +// parameter is sent. An inline object parameter gets a type named after the +// operation (base) and the parameter. +func (g *FetchGenerator) paramExtensions(op *codegen.CodegenOperation, ext map[string]any, base string) { + taken := make(map[string]int) + fields := []map[string]any{} + byLocation := map[string][]map[string]any{} + files := false + body := "" + + for _, p := range op.AllParams { + vars, description := p.Vars, p.Description + if obj := inlineObject(p.Items); len(vars) == 0 && obj != nil && (p.IsArray || p.IsMap) { + vars, description = obj.Vars, obj.Description + } + + named := "" + if ref := g.refModel(p.Ref); g.enums[ref] { + named = ref + } + + typeName := g.opTypeNamer(op.OperationId) + what := "the " + p.BaseName + " parameter of " + base + + if len(vars) > 0 { + named = typeName(base + p.ParamName) + g.opInline(ext)(g.inlineStructs(named, what, description, vars, typeName)) + } + + if p.ComposedSchemas != nil { + named = typeName(base + p.ParamName) + g.opInline(ext)(g.unionDecl(named, what, p.Description, p.ComposedSchemas, typeName)) + } + + t := GoParamType(p, named) + if p.IsBodyParam && p.DataType == "Blob" && !p.IsModel { + t = "io.Reader" + } + + // A required nullable body is a pointer too: nil sends null. + if (!p.Required || p.IsBodyParam && p.IsNullable) && !isContainer(t) && !g.containers[t] { + t = "*" + t + } + + field := uniqueName(taken, p.ParamName) + fields = append(fields, map[string]any{"name": field, "type": t, "doc": docLines(p.Description)}) + + entry := map[string]any{ + "field": field, + "quoted": strconv.Quote(p.BaseName), + "style": paramStyle(p), + "explode": p.IsExplode, + } + + switch { + case p.IsBodyParam: + body = field + g.bodyKind(p, t, field, ext) + + case p.IsFormParam: + byLocation["form"] = append(byLocation["form"], entry) + files = files || strings.HasSuffix(t, "File") + + case p.IsPathParam: + entry["placeholder"] = strconv.Quote("{" + p.BaseName + "}") + byLocation["path"] = append(byLocation["path"], entry) + + case p.IsQueryParam: + byLocation["query"] = append(byLocation["query"], entry) + case p.IsHeaderParam: + byLocation["header"] = append(byLocation["header"], entry) + case p.IsCookieParam: + byLocation["cookie"] = append(byLocation["cookie"], entry) + } + } + + ext["x-go-fields"] = fields + for _, loc := range []string{"path", "query", "header", "cookie", "form"} { + ext["x-go-in-"+loc] = byLocation[loc] + } + + // ponytail: a multipart body is built in a bytes.Buffer, files included, + // so a request holds its uploads in memory; the upgrade is an io.Pipe + // that a goroutine writes once the request is sent. + // A form with a file goes as multipart whatever media type it declares, + // as upstream's Go client does: only multipart carries a file. + form := len(byLocation["form"]) > 0 + multipart := op.IsMultipart || files + ext["x-go-has-body"] = body != "" || form + ext["x-go-body-multipart"] = form && multipart + ext["x-go-body-form"] = form && !multipart +} + +// bodyKind sets how a body parameter is encoded: as it is for a reader, as +// text for a string the content type does not mark as JSON, else as JSON. +func (g *FetchGenerator) bodyKind(p *codegen.CodegenParameter, t, field string, ext map[string]any) { + ext["x-go-content-type"] = strconv.Quote(p.ContentType) + + switch { + case t == "io.Reader": + ext["x-go-body-reader"] = field + case strings.TrimPrefix(t, "*") == "string" && !isJSONMime(p.ContentType): + ext["x-go-body-text"] = field + default: + ext["x-go-body-json"] = map[string]any{"field": field, "null": p.Required && p.IsNullable} + } +} + +// opInline returns a func adding an operation's inline type declarations and +// enums to ext, for the API file to declare next to the Params type. +func (g *FetchGenerator) opInline(ext map[string]any) func(decls, enums []map[string]any) { + return func(decls, enums []map[string]any) { + declared, _ := ext["x-go-inline"].([]map[string]any) + ext["x-go-inline"] = append(declared, decls...) + + declaredEnums, _ := ext["x-go-inline-enums"].([]map[string]any) + ext["x-go-inline-enums"] = append(declaredEnums, enums...) + } +} + +// isJSONMime reports whether a media type is JSON, application/json or a +// +json suffix, as the runtime's check. +func isJSONMime(mediaType string) bool { + mt, _, _ := strings.Cut(strings.ToLower(mediaType), ";") + mt = strings.TrimSpace(mt) + + return mt == "" || mt == "application/json" || strings.HasSuffix(mt, "+json") +} + +// paramStyle is a parameter's OpenAPI style with the default of its +// location filled in. +func paramStyle(p *codegen.CodegenParameter) string { + switch { + case p.IsDeepObject: + return "deepObject" + case p.Style != "": + return p.Style + case p.IsPathParam || p.IsHeaderParam: + return "simple" + } + + return "form" +} + +// responseType is the Go type of a response body, "" for none. +func responseType(r *codegen.CodegenResponse) string { + switch { + case r.IsBinary || r.IsFile: + return "[]byte" + case r.DataType == "": + return "" + case r.ReturnProperty == nil: + return r.DataType // a $ref, which the parser names but leaves untyped + } + + return GoType(r.ReturnProperty, "") +} + +// responseExtensions types the operation's result and the model each error +// status decodes into. A result that a 2xx status sends without a body is a +// pointer, nil for that status, unless it is nil-able already or a string, +// whose empty text says the same. An inline object result gets a type named +// after the operation (base). +func (g *FetchGenerator) responseExtensions(op *codegen.CodegenOperation, ext map[string]any, base string) { + t := "" + + switch { + case op.IsResponseBinary: + t = "[]byte" + ext["x-go-decode-bytes"] = true + + case op.ReturnType == "": + if !g.streamExtensions(op, ext, base) { + ext["x-go-discard"] = true + } + + case op.ReturnProperty == nil: + t = op.ReturnType + if g.nullable[t] && !g.containers[t] { + t = "*" + t + } + + default: + t = g.resultType(op, op.ReturnProperty, base+"Response", "the response of "+base, ext) + } + + if t != "" && t != "string" && !isContainer(t) && !g.containers[t] { + var empty []string + + for _, r := range op.Responses { + if r.Is2xx && responseType(r) == "" { + empty = append(empty, r.Code) + } + } + + if len(empty) > 0 { + t = "*" + strings.TrimPrefix(t, "*") + doc, _ := ext["x-go-doc"].([]string) + ext["x-go-doc"] = append(doc, "//", "// The result is nil for a "+strings.Join(empty, " or ")+" response, which has no body.") + } + } + + ext["x-go-type"] = t + if t != "" && !op.IsResponseBinary { + ext["x-go-decode-json"] = t + g.other2xx(op, ext, base) + } + + var codes, ranges []map[string]any + + var def map[string]any + + for _, r := range op.Responses { + rt := responseType(r) + if rt == "" || rt == "[]byte" || r.Is2xx { + continue + } + + keys := g.modelKeys(rt) + + switch _, err := strconv.Atoi(r.Code); { + case r.IsDefault: + def = map[string]any{"type": rt, "keys": keys} + case len(r.Code) == 3 && strings.EqualFold(r.Code[1:], "XX"): + ranges = append(ranges, map[string]any{"digit": r.Code[:1], "type": rt, "keys": keys}) + case err == nil: + codes = append(codes, map[string]any{"code": r.Code, "type": rt, "keys": keys}) + } + } + + if len(codes) > 0 || len(ranges) > 0 || def != nil { + ext["x-go-errors"] = map[string]any{ + "codes": codes, "hasCodes": len(codes) > 0, + "ranges": ranges, "hasRanges": len(ranges) > 0, + "default": def, + } + } +} + +// modelKeys is the Go []string of the required properties of model t, the +// keys a body must hold to be decoded as one; "nil" for none. +func (g *FetchGenerator) modelKeys(t string) string { + if required := g.required[t]; required != "" { + return "[]string{" + required + "}" + } + + return "nil" +} + +// resultType is the Go type of a result or a streamed item, prop: an +// inline object or union is a type named name, declared for the operation; +// a nullable one is a pointer. +func (g *FetchGenerator) resultType(op *codegen.CodegenOperation, prop *codegen.CodegenProperty, name, what string, ext map[string]any) string { + named := "" + + typeName := g.opTypeNamer(op.OperationId) + + if obj := inlineObject(prop); obj != nil { + named = typeName(name) + g.opInline(ext)(g.inlineStructs(named, what, obj.Description, obj.Vars, typeName)) + } + + if u := inlineUnion(prop); u != nil { + named = typeName(name) + g.opInline(ext)(g.unionDecl(named, what, u.Description, u.ComposedSchemas, typeName)) + } + + t := GoType(prop, named) + if prop.IsNullable && t != "string" && !isContainer(t) && !g.containers[t] { + t = "*" + t + } + + return t +} + +// streamExtensions types an operation whose 2xx response streams its items +// (x-stream, see the parser's streamItem) and reports whether it does: JSON +// Lines as an iter.Seq2 of the item type, server-sent events as one of +// ServerSentEvent of it. Another streamed media type (json-seq, multipart) +// has only its Raw method. +func (g *FetchGenerator) streamExtensions(op *codegen.CodegenOperation, ext map[string]any, base string) bool { + for _, r := range op.Responses { + item, _ := r.VendorExtensions["x-stream-item"].(*codegen.CodegenProperty) + mediaType, _ := r.VendorExtensions["x-stream"].(string) + mediaType, _, _ = strings.Cut(mediaType, ";") + + decode := map[string]string{"application/jsonl": "decodeStream", "application/x-ndjson": "decodeStream", "text/event-stream": "decodeEvents"}[mediaType] + if !r.Is2xx || item == nil || decode == "" { + continue + } + + t := g.resultType(op, item, base+"Item", "an item of "+base, ext) + + yields := t + if decode == "decodeEvents" { + yields = "ServerSentEvent[" + t + "]" + } + + ext["x-go-stream"] = map[string]any{"item": yields, "decode": decode + "[" + t + "]"} + + doc, _ := ext["x-go-doc"].([]string) + ext["x-go-doc"] = append(doc, "//", "// Range the result to its end, break or cancel ctx: until then the response stays open.") + + return true + } + + return false +} + +// other2xx lists the 2xx statuses whose body is of another type than the +// result (a 201 Order next to a 202 Problem): the typed method returns +// them as a *ResponseError holding that body, which the result cannot. An +// inline object body gets a type named after the operation and status. +func (g *FetchGenerator) other2xx(op *codegen.CodegenOperation, ext map[string]any, base string) { + result, _ := ext["x-go-type"].(string) + if result == "any" { + return // any holds every body + } + + var codes []map[string]any + + for _, r := range op.Responses { + // An inline body of the result's shape is the result (a 200 and 201 + // of one schema); the parser gives each its own property. + if _, err := strconv.Atoi(r.Code); err != nil || !r.Is2xx || r.DataType == "" || r.ReturnProperty != nil && reflect.DeepEqual(r.ReturnProperty, op.ReturnProperty) { + continue + } + + rt := responseType(r) + if obj := inlineObject(r.ReturnProperty); obj != nil { + typeName := g.opTypeNamer(op.OperationId) + named := typeName(base + r.Code + "Response") + g.opInline(ext)(g.inlineStructs(named, "the "+r.Code+" response of "+base, obj.Description, obj.Vars, typeName)) + rt = GoType(r.ReturnProperty, named) + } + + if rt == "" || rt == "any" || rt == "[]byte" || rt == strings.TrimPrefix(result, "*") { + continue + } + + codes = append(codes, map[string]any{"code": r.Code, "type": rt, "keys": g.modelKeys(rt)}) + + doc, _ := ext["x-go-doc"].([]string) + ext["x-go-doc"] = append(doc, "//", "// A "+r.Code+" response is a *ResponseError with its "+rt+" body in Model.") + } + + if len(codes) > 0 { + ext["x-go-other-2xx"] = map[string]any{"codes": codes} + } +} + +// authEntries lists how each security scheme of the operation is sent. +func authEntries(op *codegen.CodegenOperation) []map[string]any { + opName := op.OperationIdOriginal + if opName == "" { + opName = op.OperationId + } + + entries := []map[string]any{} + + for _, a := range op.AuthMethods { + e := map[string]any{ + "op": strconv.Quote(opName), "scheme": strconv.Quote(a.Name), + "apiKey": false, "basic": false, "bearer": false, + } + + switch { + case a.IsApiKey: + in := "header" + if a.IsKeyInQuery { + in = "query" + } else if a.IsKeyInCookie { + in = "cookie" + } + + e["apiKey"], e["key"], e["in"] = true, strconv.Quote(a.KeyParamName), in + + case a.IsBasicBasic: + e["basic"] = true + case a.IsBasicBearer || a.IsOAuth || a.IsOpenId: + scopes := "nil" + + if len(a.Scopes) > 0 { + quoted := make([]string, len(a.Scopes)) + for i, s := range a.Scopes { + quoted[i] = strconv.Quote(fmt.Sprint(s["scope"])) + } + + scopes = "[]string{" + strings.Join(quoted, ", ") + "}" + } + + e["bearer"], e["scopes"] = true, scopes + + default: + continue + } + + entries = append(entries, e) + } + + return entries +} diff --git a/internal/generator/golang/fetch_test.go b/internal/generator/golang/fetch_test.go new file mode 100644 index 0000000..110078c --- /dev/null +++ b/internal/generator/golang/fetch_test.go @@ -0,0 +1,526 @@ +package golang + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/xseman/openapi-generator/internal/codegen" + "github.com/xseman/openapi-generator/internal/parser" +) + +// parse runs a spec (a feature spec name or inline YAML) through the parser +// and the generator, as the pipeline does. +func parse(t *testing.T, spec string) (*FetchGenerator, *parser.Parser) { + t.Helper() + + g := NewFetchGenerator() + if err := g.ProcessOpts(); err != nil { + t.Fatal(err) + } + + p := parser.NewParser() + p.GetTypeFunc, p.ToModelNameFunc, p.ToVarNameFunc = g.GetSchemaType, g.ToModelName, g.ToVarName + p.SkipValidation = true + + var err error + if strings.Contains(spec, "\n") { + err = p.LoadFromData([]byte(spec)) + } else { + err = p.LoadFromFile(filepath.Join("..", "..", "..", "testdata", "specs", "feature", spec+".yaml")) + } + + if err != nil { + t.Fatal(err) + } + + return g, p +} + +func models(t *testing.T, spec string) map[string]*codegen.CodegenModel { + t.Helper() + + g, p := parse(t, spec) + + ms, err := p.GetModels() + if err != nil { + t.Fatal(err) + } + + byName := make(map[string]*codegen.CodegenModel) + for _, m := range g.PostProcessModels(ms) { + byName[m.Classname] = m + } + + return byName +} + +// fields renders a struct model's fields as "Name Type Tag" lines. +func fields(m *codegen.CodegenModel) string { + fs, _ := m.VendorExtensions["x-go-fields"].([]map[string]any) + + lines := make([]string, len(fs)) + for i, f := range fs { + lines[i] = fmt.Sprintf("%v %v %v", f["name"], f["type"], f["tag"]) + } + + return strings.Join(lines, "\n") +} + +func TestAllOfFlattened(t *testing.T) { + tests := []struct { + spec, model, want string + }{ + {"composition-allof-inheritance", "Car", "ID string `json:\"id\"`\nMake string `json:\"make\"`\nDoors int `json:\"doors\"`\nTrunkVolumeLiters Opt[float64] `json:\"trunkVolumeLiters,omitzero\"`"}, + {"composition-allof-inheritance", "SportsCar", "ID string `json:\"id\"`\nMake string `json:\"make\"`\nDoors int `json:\"doors\"`\nTrunkVolumeLiters Opt[float64] `json:\"trunkVolumeLiters,omitzero\"`\nTopSpeedKph *int `json:\"topSpeedKph,omitempty\"`"}, + {"discriminator-mapping", "Cat", "PetType string `json:\"petType\"`\nName *string `json:\"name,omitempty\"`\nHuntingSkill *string `json:\"huntingSkill,omitempty\"`"}, + {"discriminator-mapping", "Dog", "PetType string `json:\"petType\"`\nName *string `json:\"name,omitempty\"`\nPackSize *int `json:\"packSize,omitempty\"`"}, + {"discriminator-self-ref", "LeafNode", "NodeType string `json:\"nodeType\"`\nChildren []TreeNode `json:\"children,omitzero\"`\nValue *string `json:\"value,omitempty\"`"}, + } + + for _, tt := range tests { + if got := fields(models(t, tt.spec)[tt.model]); got != tt.want { + t.Errorf("%s fields:\n%s\nwant:\n%s", tt.model, got, tt.want) + } + } +} + +func TestEnums(t *testing.T) { + ms := models(t, "enum-edge-values") + + tests := []struct { + model, base string + literals []string + }{ + {"Level", "int", []string{"-1", "1", "2"}}, + {"Severity", "string", []string{`"low"`, `"LOW"`, `"it's"`, `"a\\b"`}}, + {"State", "string", []string{`"placed"`, `"approved"`}}, + {"Rating", "int", []string{"1", "5"}}, + } + + for _, tt := range tests { + enums, _ := ms[tt.model].VendorExtensions["x-go-enums"].([]map[string]any) + if len(enums) != 1 || enums[0]["base"] != tt.base { + t.Errorf("%s enums = %v, want one of base %s", tt.model, enums, tt.base) + continue + } + + values, _ := enums[0]["values"].([]map[string]any) + + var literals []string + for _, v := range values { + literals = append(literals, fmt.Sprint(v["literal"])) + } + + if strings.Join(literals, " ") != strings.Join(tt.literals, " ") { + t.Errorf("%s literals = %v, want %v", tt.model, literals, tt.literals) + } + } + + // A property that refers to an enum model takes its type, no copy. + want := "Level Opt[Level] `json:\"level,omitzero\"`\nSeverity *Severity `json:\"severity,omitempty\"`\n" + + "State *State `json:\"state,omitempty\"`\nRating *Rating `json:\"rating,omitempty\"`" + if got := fields(ms["Levels"]); got != want { + t.Errorf("Levels fields:\n%s\nwant:\n%s", got, want) + } + + if enums, _ := ms["Levels"].VendorExtensions["x-go-enums"].([]map[string]any); len(enums) != 0 { + t.Errorf("Levels declares enums %v", enums) + } +} + +func TestCyclesAndCollisions(t *testing.T) { + ms := models(t, `openapi: 3.0.0 +info: {title: t, version: "1"} +paths: {} +components: + schemas: + A: + type: object + required: [b] + properties: {b: {$ref: "#/components/schemas/B"}} + B: + type: object + required: [a] + properties: {a: {$ref: "#/components/schemas/A"}} + Node: + type: object + required: [next, id, ID] + properties: + next: {$ref: "#/components/schemas/Node"} + id: {type: string} + ID: {type: string} + parent: {allOf: [{$ref: "#/components/schemas/Node"}], nullable: true} + Names: + type: object + required: [foo2, foo, Foo] + properties: {foo2: {type: string}, foo: {type: string}, Foo: {type: string}} + Owner: + type: object + required: [pet] + properties: + pet: {$ref: "#/components/schemas/Pet"} + pets: {type: array, items: {$ref: "#/components/schemas/Pet"}} + Pet: + oneOf: [{$ref: "#/components/schemas/A"}, {$ref: "#/components/schemas/B"}] +`) + + for model, want := range map[string]string{ + "A": "B B `json:\"b\"`", + "B": "A *A `json:\"a\"`", + "Node": "Next *Node `json:\"next\"`\nID string `json:\"id\"`\nID2 string `json:\"ID\"`\nParent Opt[*Node] `json:\"parent,omitzero\"`", + "Names": "Foo2 string `json:\"foo2\"`\nFoo string `json:\"foo\"`\nFoo3 string `json:\"Foo\"`", + "Owner": "Pet Pet `json:\"pet\"`\nPets []Pet `json:\"pets,omitzero\"`", + } { + if got := fields(ms[model]); got != want { + t.Errorf("%s fields:\n%s\nwant:\n%s", model, got, want) + } + } +} + +func TestResponseTypes(t *testing.T) { + g, p := parse(t, `openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /pet: + get: + operationId: getPet + responses: + "200": {description: ok, content: {application/json: {schema: {$ref: "#/components/schemas/Pet"}}}} + "4XX": {description: client, content: {application/json: {schema: {type: array, items: {type: string}}}}} + /pets: + get: + operationId: listPets + responses: + "200": {description: ok, content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/Pet"}}}}} + /photo: + get: + operationId: getPhoto + responses: + "200": {description: ok, content: {image/png: {schema: {type: string, format: binary}}}} + /ping: + get: + operationId: ping + responses: + "204": {description: ok} + /job: + post: + operationId: startJob + responses: + "201": {description: ok, content: {application/json: {schema: {$ref: "#/components/schemas/Pet"}}}} + "202": {description: accepted} + get: + operationId: listJobs + responses: + "200": {description: ok, content: {application/json: {schema: {type: array, items: {type: string}}}}} + "204": {description: none} + /records: + post: + operationId: setRecords + responses: + "200": {description: ok, content: {application/json: {schema: {type: object, properties: {total: {type: integer}}}}}} + "201": {description: new, content: {application/json: {schema: {type: object, properties: {total: {type: integer}}}}}} + "206": {description: some, content: {application/json: {schema: {type: array, items: {type: object, properties: {id: {type: string}}}}}}} + "207": {description: partly, content: {application/json: {schema: {type: object, properties: {errors: {type: array, items: {type: string}}}}}}} + /maybe: + get: + operationId: getMaybe + responses: + "200": {description: ok, content: {application/json: {schema: {$ref: "#/components/schemas/Maybe"}}}} + "204": {description: none} + put: + operationId: listMaybe + responses: + "200": {description: ok, content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/Maybe"}}}}} +components: + schemas: + Pet: {type: object, properties: {name: {type: string}}} + GetPetParams: {type: object, properties: {id: {type: string}}} + Maybe: {type: object, nullable: true, properties: {name: {type: string}}} +`) + + byTag, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + want := map[string]string{ + "getPet": "Pet", "listPets": "[]Pet", "getPhoto": "[]byte", "ping": "", + "startJob": "*Pet", "listJobs": "[]string", "setRecords": "SetRecordsResponse", + "getMaybe": "*Maybe", "listMaybe": "[]*Maybe", + } + + // The GetPetParams schema takes the name of getPet's Params type. + ms, err := p.GetModels() + if err != nil { + t.Fatal(err) + } + + g.PostProcessModels(ms) + + for _, op := range g.PostProcessOperations(byTag["default"]) { + if got := op.VendorExtensions["x-go-type"]; got != want[op.OperationId] { + t.Errorf("%s returns %q, want %q", op.OperationId, got, want[op.OperationId]) + } + + if params := op.VendorExtensions["x-go-params"]; op.OperationId == "getPet" && params != "GetPetParams2" { + t.Errorf("getPet params = %v, want GetPetParams2 next to the GetPetParams model", params) + } + + // Another inline object of a 2xx is a type of its own, not the result; + // one of the result's shape is the result. + if other, _ := op.VendorExtensions["x-go-other-2xx"].(map[string]any); op.OperationId == "setRecords" { + codes, _ := other["codes"].([]map[string]any) + if got := fmt.Sprint(codes); len(codes) != 2 || codes[0]["type"] != "[]SetRecords206Response" || codes[1]["type"] != "SetRecords207Response" { + t.Errorf("setRecords other 2xx = %s, want 206 of []SetRecords206Response and 207 of SetRecords207Response", got) + } + } + + if doc := fmt.Sprint(op.VendorExtensions["x-go-doc"]); op.OperationId == "getMaybe" && !strings.Contains(doc, "nil for a 204") { + t.Errorf("getMaybe doc = %s, want the 204 that leaves it nil", doc) + } + + if op.OperationId == "getPet" { + errs, _ := op.VendorExtensions["x-go-errors"].(map[string]any) + if ranges, _ := errs["ranges"].([]map[string]any); len(ranges) != 1 || ranges[0]["digit"] != "4" || ranges[0]["type"] != "[]string" { + t.Errorf("getPet error ranges = %v, want 4XX of []string", errs["ranges"]) + } + } + } +} + +// TestAnyOfImplicitDiscriminator checks that an anyOf with a discriminator +// and no mapping maps each member's schema name to it, as a oneOf does. +func TestAnyOfImplicitDiscriminator(t *testing.T) { + ms := models(t, `openapi: 3.0.0 +info: {title: t, version: "1"} +paths: {} +components: + schemas: + Pet: + anyOf: [{$ref: "#/components/schemas/Cat"}, {$ref: "#/components/schemas/Dog"}] + discriminator: {propertyName: kind} + Cat: {type: object, required: [kind], properties: {kind: {type: string}}} + Dog: {type: object, required: [kind], properties: {kind: {type: string}}} +`) + + d, _ := ms["Pet"].VendorExtensions["x-go-discriminator"].(map[string]any) + if got := fmt.Sprint(d["mappings"]); got != `[map[type:Cat value:"Cat"] map[type:Dog value:"Dog"]]` { + t.Errorf("mappings = %s", got) + } +} + +// TestEnumRefParam types a parameter that refers to an enum model as that +// model, not as its base type. +func TestEnumRefParam(t *testing.T) { + g, p := parse(t, `openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /paint/{color}: + get: + operationId: paint + parameters: + - {name: color, in: path, required: true, schema: {$ref: "#/components/schemas/Color"}} + - {name: also, in: query, schema: {type: array, items: {$ref: "#/components/schemas/Color"}}} + - {name: either, in: query, schema: {$ref: "#/components/schemas/Either"}} + responses: {"204": {description: ok}} +components: + schemas: + Color: {type: string, enum: [red, light green]} + Either: {oneOf: [{type: string}, {type: integer}]} +`) + + ms, err := p.GetModels() + if err != nil { + t.Fatal(err) + } + + g.PostProcessModels(ms) + + byTag, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + op := g.PostProcessOperations(byTag["default"])[0] + fs, _ := op.VendorExtensions["x-go-fields"].([]map[string]any) + + // A union stays raw JSON, which the runtime sends as the scalar it holds. + if got := fmt.Sprint(fs[0]["type"], " ", fs[1]["type"], " ", fs[2]["type"]); got != "Color []Color json.RawMessage" { + t.Errorf("param types = %s, want Color []Color json.RawMessage", got) + } +} + +// TestModelShapes covers the model kinds the feature specs leave out: +// aliases of each type, nullable items, bool and float enums, union +// variants of arrays and maps, implied discriminators that do not hold, and +// a property name that needs a quoted tag. +func TestModelShapes(t *testing.T) { + ms := models(t, `openapi: 3.0.0 +info: {title: t, version: "1"} +paths: {} +components: + schemas: + Pet: {type: object, required: [kind], properties: {kind: {type: string, enum: [pet]}}} + Pets: {type: array, items: {$ref: "#/components/schemas/Pet"}} + Counts: {type: object, additionalProperties: {type: integer}} + Name: {type: string} + Age: {type: integer} + Weight: {type: number} + Flag: {type: boolean} + Free: {type: object} + Sparse: + type: object + properties: + list: {type: array, items: {type: integer, nullable: true}} + map: {type: object, additionalProperties: {type: string, nullable: true}} + "a`+"`"+`b": {type: string} + Toggle: {type: boolean, enum: [true, false]} + Ratio: {type: number, enum: [0.5, 1, 1.5]} + Mixed: + oneOf: + - $ref: "#/components/schemas/Pet" + - {type: array, items: {$ref: "#/components/schemas/Pet"}} + - {type: object, additionalProperties: {type: string}} + Same: + oneOf: + - $ref: "#/components/schemas/Pet" + - $ref: "#/components/schemas/Other" + Other: {type: object, required: [kind], properties: {kind: {type: string, enum: [pet]}}} +`) + + for model, want := range map[string]string{ + "Pets": "[]Pet", "Counts": "map[string]int", "Name": "string", "Age": "int", + "Weight": "float64", "Flag": "bool", "Free": "map[string]any", + } { + if got := ms[model].VendorExtensions["x-go-alias-type"]; got != want { + t.Errorf("%s = %v, want %s", model, got, want) + } + } + + if got, want := fields(ms["Sparse"]), "List []*int `json:\"list,omitzero\"`\nMap map[string]*string `json:\"map,omitzero\"`\n"+ + "AB *string \"json:\\\"a`b,omitempty\\\"\""; got != want { + t.Errorf("Sparse fields:\n%s\nwant:\n%s", got, want) + } + + for model, want := range map[string]string{"Toggle": "bool", "Ratio": "float64"} { + enums, _ := ms[model].VendorExtensions["x-go-enums"].([]map[string]any) + if len(enums) != 1 || enums[0]["base"] != want { + t.Errorf("%s enum = %v, want base %s", model, enums, want) + } + } + + variants, _ := ms["Mixed"].VendorExtensions["x-go-variants"].([]map[string]any) + + var methods []string + for _, v := range variants { + methods = append(methods, fmt.Sprint(v["method"])) + } + + if got := strings.Join(methods, " "); got != "Pet PetList StringMap" { + t.Errorf("Mixed variants = %s", got) + } + + // Pet and Other both pin kind to "pet": kind tells them apart from no other. + if d := ms["Same"].VendorExtensions["x-go-discriminator"]; d != false { + t.Errorf("Same discriminator = %v, want none", d) + } +} + +// TestOperationShapes covers operation naming the feature specs leave out: +// a FooRaw next to Foo, an operation under two tags naming its inline types +// once, and an array of inline objects as a parameter. +func TestOperationShapes(t *testing.T) { + g, p := parse(t, `openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /foo: + get: + operationId: foo + responses: {"204": {description: ok}} + /foo/raw: + get: + operationId: fooRaw + responses: {"204": {description: ok}} + /shared: + post: + operationId: shared + tags: [a, b] + parameters: + - name: points + in: query + content: + application/json: + schema: {type: array, items: {type: object, properties: {x: {type: integer}}}} + responses: + "200": + description: ok + content: + application/json: + schema: {type: object, properties: {id: {type: string}}} +`) + + byTag, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + names := map[string]string{} + for _, op := range g.PostProcessOperations(byTag["default"]) { + names[op.OperationId] = fmt.Sprint(op.VendorExtensions["x-go-name"]) + } + + if names["foo"] != "Foo" || names["fooRaw"] != "FooRawMethod" { + t.Errorf("names = %v, want Foo and FooRawMethod", names) + } + + for _, tag := range []string{"a", "b"} { + op := g.PostProcessOperations(byTag[tag])[0] + + if got := op.VendorExtensions["x-go-type"]; got != "SharedResponse" { + t.Errorf("tag %s: result %v, want SharedResponse each time", tag, got) + } + + fs, _ := op.VendorExtensions["x-go-fields"].([]map[string]any) + if got := fs[0]["type"]; got != "[]SharedPoints" { + t.Errorf("tag %s: points %v, want []SharedPoints", tag, got) + } + } +} + +// TestBodyNamedBlob: a model named Blob, the parser's name for binary, is +// still a JSON body; only a binary body is an io.Reader. +func TestBodyNamedBlob(t *testing.T) { + g, p := parse(t, `openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /blob: + post: + operationId: putBlob + requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/Blob"}}}} + responses: {"204": {description: ok}} + /raw: + post: + operationId: putRaw + requestBody: {required: true, content: {application/octet-stream: {schema: {type: string, format: binary}}}} + responses: {"204": {description: ok}} +components: + schemas: + Blob: {type: object, properties: {data: {type: string}}} +`) + + byTag, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + want := map[string]string{"putBlob": "Blob", "putRaw": "io.Reader"} + + for _, op := range g.PostProcessOperations(byTag["default"]) { + fs, _ := op.VendorExtensions["x-go-fields"].([]map[string]any) + if got := fs[0]["type"]; got != want[op.OperationId] { + t.Errorf("%s body = %v, want %s", op.OperationId, got, want[op.OperationId]) + } + } +} diff --git a/internal/parser/arraymodel_test.go b/internal/parser/arraymodel_test.go new file mode 100644 index 0000000..5536404 --- /dev/null +++ b/internal/parser/arraymodel_test.go @@ -0,0 +1,31 @@ +package parser + +import "testing" + +// TestArrayModelItemsRef checks that an array schema whose items are a $ref +// types its items as the referenced model, not the model's inline shape. +func TestArrayModelItemsRef(t *testing.T) { + p := NewParser() + + p.SkipValidation = true + if err := p.LoadFromData([]byte(` +openapi: 3.0.0 +info: {title: t, version: "1"} +paths: {} +components: + schemas: + Pets: {type: array, items: {$ref: "#/components/schemas/Pet"}} + Pet: {type: object, properties: {name: {type: string}}} +`)); err != nil { + t.Fatalf("LoadFromData: %v", err) + } + + models, err := p.GetModels() + if err != nil { + t.Fatalf("GetModels: %v", err) + } + + if m := findModel(t, models, "Pets"); m.Items == nil || !m.Items.IsModel || m.ArrayModelType != "Pet" { + t.Errorf("Pets items = %+v, array model type %q, want the Pet model", m.Items, m.ArrayModelType) + } +} diff --git a/internal/parser/dedupe_test.go b/internal/parser/dedupe_test.go new file mode 100644 index 0000000..3816836 --- /dev/null +++ b/internal/parser/dedupe_test.go @@ -0,0 +1,47 @@ +package parser + +import ( + "strings" + "testing" +) + +// TestDeduplicateParams keeps parameters that share a name only once +// camelCased, each under its own name, and lets an operation's parameter +// replace its path item's of the same location and name. +func TestDeduplicateParams(t *testing.T) { + p := NewParser() + + p.SkipValidation = true + if err := p.LoadFromData([]byte(` +openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /things/{item_id}: + parameters: + - {name: item_id, in: path, required: true, schema: {type: string}} + get: + operationId: getThing + parameters: + - {name: item_id, in: path, required: true, schema: {type: integer}} + - {name: Item-Id, in: header, schema: {type: string}} + - {name: sort_by, in: query, schema: {type: string}} + - {name: sortBy, in: query, schema: {type: string}} + responses: {"204": {description: ok}} +`)); err != nil { + t.Fatal(err) + } + + ops, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + var got []string + for _, param := range ops["default"][0].AllParams { + got = append(got, param.ParamName+"="+param.BaseName+":"+param.DataType) + } + + if want := "itemId=item_id:number itemId2=Item-Id:string sortBy=sort_by:string sortBy2=sortBy:string"; strings.Join(got, " ") != want { + t.Errorf("params = %s\nwant %s", strings.Join(got, " "), want) + } +} diff --git a/internal/parser/load.go b/internal/parser/load.go index eb2271c..343dd96 100644 --- a/internal/parser/load.go +++ b/internal/parser/load.go @@ -10,12 +10,14 @@ import ( "net/url" "os" "path/filepath" + "regexp" "sort" "strings" "github.com/getkin/kin-openapi/openapi2" "github.com/getkin/kin-openapi/openapi2conv" "github.com/getkin/kin-openapi/openapi3" + "github.com/oasdiff/yaml" ) // LoadFromFile loads an OpenAPI spec from a file. @@ -47,6 +49,7 @@ func (p *Parser) LoadFromFile(path string) error { p.Doc = doc p.buildPropertyOrder(data) + p.normalize() // Validate the spec (unless skipped) return p.validateSpec() @@ -92,6 +95,7 @@ func (p *Parser) LoadFromURL(urlStr string) error { p.Doc = doc p.buildPropertyOrder(data) + p.normalize() // Validate the spec (unless skipped) return p.validateSpec() @@ -114,6 +118,7 @@ func (p *Parser) LoadFromData(data []byte) error { p.Doc = doc p.buildPropertyOrder(data) + p.normalize() // Validate the spec (unless skipped) return p.validateSpec() @@ -121,24 +126,35 @@ func (p *Parser) LoadFromData(data []byte) error { // isSwagger2 checks if the data represents a Swagger 2.0 specification. func isSwagger2(data []byte) bool { - // Simple check: look for "swagger": "2.0" in the JSON/YAML var temp struct { - Swagger string `json:"swagger" yaml:"swagger"` + Swagger string `json:"swagger"` } - // Try JSON first if err := json.Unmarshal(data, &temp); err == nil { return strings.HasPrefix(temp.Swagger, "2.") } - return false + return swagger2YAML.Match(data) } +// swagger2YAML finds a top-level swagger: "2.x" key in a YAML document, +// without converting a large OpenAPI 3 one to JSON to look. +var swagger2YAML = regexp.MustCompile(`(?m)^["']?swagger["']?[ \t]*:[ \t]*["']?2\.`) + // loadSwagger2FromData loads a Swagger 2.0 spec and converts it to OpenAPI 3. func (p *Parser) loadSwagger2FromData(data []byte) error { var doc2 openapi2.T - if err := json.Unmarshal(data, &doc2); err != nil { + // YAMLToJSON sorts the keys: the property order reads data itself. + js := data + if !json.Valid(data) { + var err error + if js, err = yaml.YAMLToJSON(data); err != nil { + return fmt.Errorf("failed to parse Swagger 2.0 spec: %w", err) + } + } + + if err := json.Unmarshal(js, &doc2); err != nil { return fmt.Errorf("failed to parse Swagger 2.0 spec: %w", err) } @@ -256,7 +272,22 @@ func (p *Parser) validateSpec() error { return errors.New("no document loaded") } - if err := p.Doc.Validate(context.Background()); err != nil { + // FastAPI writes 3.2's itemSchema (a streamed response) into a 3.1 + // document: a 3.1 one may use what 3.2 adds. + // The other fields 3.2 adds hold operations, which no generator reads: + // those stay an error. + var opts []openapi3.ValidationOption + if strings.HasPrefix(p.Doc.OpenAPI, "3.1") { + opts = append(opts, openapi3.IsOpenAPI32OrLater()) + + for _, path := range p.Doc.Paths.InMatchingOrder() { + if item := p.Doc.Paths.Value(path); item.Query != nil || len(item.AdditionalOperations) > 0 { + p.ValidationErrors = append(p.ValidationErrors, "path "+path+": query and additionalOperations are for OpenAPI >=3.2") + } + } + } + + if err := p.Doc.Validate(context.Background(), opts...); err != nil { p.ValidationErrors = append(p.ValidationErrors, err.Error()) } @@ -365,9 +396,8 @@ func (p *Parser) markSchemasInPathItem(pathItem *openapi3.PathItem, usedSchemas for _, response := range op.Responses.Map() { if response != nil && response.Value != nil { for _, content := range response.Value.Content { - if content.Schema != nil { - p.markSchemaAsUsed(content.Schema, usedSchemas) - } + p.markSchemaAsUsed(content.Schema, usedSchemas) + p.markSchemaAsUsed(content.ItemSchema, usedSchemas) } } } @@ -442,4 +472,6 @@ func (p *Parser) markSchemaPropertiesAsUsed(schema *openapi3.Schema, usedSchemas for _, s := range schema.OneOf { p.markSchemaAsUsed(s, usedSchemas) } + + p.markSchemaAsUsed(schema.ContentSchema, usedSchemas) } diff --git a/internal/parser/model.go b/internal/parser/model.go index b12a071..caa23ab 100644 --- a/internal/parser/model.go +++ b/internal/parser/model.go @@ -76,7 +76,7 @@ func (p *Parser) schemaToModel(name string, schema *openapi3.Schema) *codegen.Co // Try to infer type if len(schema.Enum) > 0 { model.IsEnum = true - } else if schema.Properties != nil { + } else if len(schema.Properties) > 0 { schemaType = &openapi3.Types{"object"} } } @@ -126,7 +126,8 @@ func (p *Parser) schemaToModel(name string, schema *openapi3.Schema) *codegen.Co case "array": model.IsArray = true if schema.Items != nil && schema.Items.Value != nil { - model.Items = p.schemaToProperty("items", schema.Items.Value, false) + // By reference, so a $ref item keeps its model name. + model.Items = p.schemaRefToProperty("items", schema.Items, false) model.ArrayModelType = model.Items.DataType } diff --git a/internal/parser/normalize.go b/internal/parser/normalize.go new file mode 100644 index 0000000..96bbf89 --- /dev/null +++ b/internal/parser/normalize.go @@ -0,0 +1,285 @@ +package parser + +import ( + "fmt" + "mime" + "slices" + + "github.com/getkin/kin-openapi/openapi3" +) + +// normalize applies two of the upstream OpenAPINormalizer's default rules to +// every schema of the loaded document, so an OpenAPI 3.1 spec reads as the +// 3.0 shapes the parser knows: +// +// - NORMALIZE_31SPEC: `type: [T, "null"]` is T with nullable, `const: v` +// an enum of v, a null among enum values nullable, and a string of +// contentMediaType application/octet-stream a binary string. +// - SIMPLIFY_ONEOF_ANYOF: a `{type: "null"}` member of oneOf/anyOf makes +// the schema nullable instead; when one member is left after that, it is +// the schema (a $ref one as `allOf: [$ref]`, the 3.0 spelling of a +// nullable $ref). Upstream also unwraps a lone member without a null. +// - SIMPLIFY_ONEOF_ANYOF_ENUM: a oneOf/anyOf of enums of one type is one +// enum of all their values (a union of pydantic Literals). +// +// It runs after the property order is recorded and keeps the schema pointers +// that order is keyed on. +func (p *Parser) normalize() { + seen := make(map[*openapi3.Schema]bool) + + walk := func(ref *openapi3.SchemaRef) { p.normalizeSchemaRef(ref, seen) } + + if c := p.Doc.Components; c != nil { + for _, ref := range c.Schemas { + walk(ref) + } + + for _, param := range c.Parameters { + normalizeParameter(param, walk) + } + + for _, body := range c.RequestBodies { + if body.Value != nil { + normalizeContent(body.Value.Content, walk) + } + } + + for _, resp := range c.Responses { + normalizeResponse(resp, walk) + } + } + + if p.Doc.Paths == nil { + return + } + + for _, item := range p.Doc.Paths.Map() { + for _, param := range item.Parameters { + normalizeParameter(param, walk) + } + + for _, op := range item.Operations() { + for _, param := range op.Parameters { + normalizeParameter(param, walk) + } + + if op.RequestBody != nil && op.RequestBody.Value != nil { + normalizeContent(op.RequestBody.Value.Content, walk) + } + + if op.Responses != nil { + for _, resp := range op.Responses.Map() { + normalizeResponse(resp, walk) + } + } + } + } +} + +func normalizeParameter(param *openapi3.ParameterRef, walk func(*openapi3.SchemaRef)) { + if param == nil || param.Value == nil { + return + } + + if param.Value.Schema != nil { + walk(param.Value.Schema) + } + + normalizeContent(param.Value.Content, walk) +} + +func normalizeResponse(resp *openapi3.ResponseRef, walk func(*openapi3.SchemaRef)) { + if resp == nil || resp.Value == nil { + return + } + + normalizeContent(resp.Value.Content, walk) + + for _, h := range resp.Value.Headers { + if h.Value != nil && h.Value.Schema != nil { + walk(h.Value.Schema) + } + } +} + +func normalizeContent(content openapi3.Content, walk func(*openapi3.SchemaRef)) { + for _, mt := range content { + if mt.Schema != nil { + walk(mt.Schema) + } + + if mt.ItemSchema != nil { + walk(mt.ItemSchema) + } + } +} + +// normalizeSchemaRef normalizes ref in place and every schema below it, each +// once. A $ref is left as it is: its target is a component, walked as one. +func (p *Parser) normalizeSchemaRef(ref *openapi3.SchemaRef, seen map[*openapi3.Schema]bool) { + if ref == nil || ref.Ref != "" || ref.Value == nil || seen[ref.Value] { + return + } + + s := ref.Value + seen[s] = true + + if s.Type != nil && s.Type.IsMultiple() && s.Type.IncludesNull() { + s.Type.Without(openapi3.TypeNull) + s.Nullable = true + } + + if s.Const != nil && len(s.Enum) == 0 { + s.Enum, s.Const = []any{s.Const}, nil + } + + // A null among the values (pydantic's Literal["x", None]) makes the + // schema nullable; the value stays, as a 3.0 nullable enum lists it. + if slices.Contains(s.Enum, nil) { + s.Nullable = true + } + + if (s.Type.Is(openapi3.TypeString) || s.Type.IsEmpty()) && s.Format == "" && s.ContentEncoding == "" { + if mt, _, _ := mime.ParseMediaType(s.ContentMediaType); mt == "application/octet-stream" { + s.Type, s.Format = &openapi3.Types{openapi3.TypeString}, "binary" + } + } + + s.OneOf = withoutNull(s, s.OneOf) + s.AnyOf = withoutNull(s, s.AnyOf) + + for _, members := range []openapi3.SchemaRefs{s.AllOf, s.OneOf, s.AnyOf} { + for _, m := range members { + p.normalizeSchemaRef(m, seen) + } + } + + // A member that takes null makes its union take it too. + for _, m := range slices.Concat(s.OneOf, s.AnyOf) { + if m.Value != nil && m.Value.Nullable { + s.Nullable = true + } + } + + for _, prop := range s.Properties { + p.normalizeSchemaRef(prop, seen) + } + + p.normalizeSchemaRef(s.Items, seen) + p.normalizeSchemaRef(s.AdditionalProperties.Schema, seen) + p.normalizeSchemaRef(s.Not, seen) + p.normalizeSchemaRef(s.ContentSchema, seen) + + mergeEnums(s) + + // The schema is rewritten where it is, so a $ref to it (a component) + // sees the result too; an inline member keeps its property order. + if one := collapse(s); one != nil { + if order, ok := p.propOrder[one]; ok { + p.propOrder[s] = order + } + + *s = *one + } +} + +// withoutNull drops the `type: "null"` members of a oneOf/anyOf and makes s +// nullable when there was one. +func withoutNull(s *openapi3.Schema, members openapi3.SchemaRefs) openapi3.SchemaRefs { + kept := members[:0:0] + + for _, m := range members { + if m.Ref == "" && m.Value != nil && m.Value.Type.Is(openapi3.TypeNull) { + s.Nullable = true + continue + } + + kept = append(kept, m) + } + + if len(kept) == len(members) { + return members + } + + return kept +} + +// collapse returns the one member a nullable oneOf/anyOf has left, as the +// schema s stands for, or nil when s is anything more. +func collapse(s *openapi3.Schema) *openapi3.Schema { + members := s.OneOf + if len(s.AnyOf) > 0 { + members = s.AnyOf + } + + if !s.Nullable || len(members) != 1 || len(s.OneOf)+len(s.AnyOf) != 1 || + len(s.AllOf) > 0 || len(s.Properties) > 0 || !s.Type.IsEmpty() || s.Discriminator != nil { + return nil + } + + m := members[0] + if m.Ref != "" { + return &openapi3.Schema{ + AllOf: openapi3.SchemaRefs{m}, Nullable: true, + Title: s.Title, Description: s.Description, Default: s.Default, + ReadOnly: s.ReadOnly, WriteOnly: s.WriteOnly, Deprecated: s.Deprecated, + } + } + + if m.Value == nil { + return nil + } + + v := m.Value + v.Nullable = true + v.ReadOnly = v.ReadOnly || s.ReadOnly + v.WriteOnly = v.WriteOnly || s.WriteOnly + + if v.Description == "" { + v.Description = s.Description + } + + if v.Title == "" { + v.Title = s.Title + } + + if v.Default == nil { + v.Default = s.Default + } + + return v +} + +// mergeEnums turns a oneOf/anyOf whose members are all enums of the same +// type into one enum of their values. +func mergeEnums(s *openapi3.Schema) { + members := s.OneOf + if len(s.AnyOf) > 0 { + members = s.AnyOf + } + + if len(members) < 2 || len(s.OneOf)+len(s.AnyOf) != len(members) || len(s.AllOf) > 0 || s.Not != nil { + return + } + + var values []any + + seen := make(map[string]bool) // by %#v: an enum value may be a map, which == panics on + + for _, m := range members { + if m.Value == nil || len(m.Value.Enum) == 0 || !slices.Equal(m.Value.Type.Slice(), members[0].Value.Type.Slice()) { + return + } + + for _, v := range m.Value.Enum { + if key := fmt.Sprintf("%#v", v); !seen[key] { + seen[key] = true + + values = append(values, v) + } + } + } + + t := members[0].Value.Type.Clone() + s.Type, s.Enum, s.OneOf, s.AnyOf = &t, values, nil, nil +} diff --git a/internal/parser/normalize_test.go b/internal/parser/normalize_test.go new file mode 100644 index 0000000..bb249f0 --- /dev/null +++ b/internal/parser/normalize_test.go @@ -0,0 +1,80 @@ +package parser + +import ( + "fmt" + "path/filepath" + "testing" +) + +// TestNormalize31 checks that the 3.1 spellings of null and of a binary +// string read as their 3.0 shapes: the member's type, nullable. +func TestNormalize31(t *testing.T) { + p := NewParser() + + p.SkipValidation = true + if err := p.LoadFromFile(filepath.Join("..", "..", "testdata", "specs", "feature", "nullable-openapi31.yaml")); err != nil { + t.Fatal(err) + } + + models, err := p.GetModels() + if err != nil { + t.Fatal(err) + } + + var got map[string]string + + for _, m := range models { + if m.Classname != "Pet" { + continue + } + + got = make(map[string]string) + for _, v := range m.Vars { + got[v.BaseName] = fmt.Sprintf("%s nullable=%t ref=%s %s", v.DataType, v.IsNullable, v.Ref, v.Description) + } + + // A const is an enum of it; a choice of consts one enum of them all. + for _, v := range m.Vars { + want := map[string]string{"kind": "[pet]", "mood": "[happy sad]"}[v.BaseName] + if got := fmt.Sprint(v.AllowableValues["values"]); want != "" && (!v.IsEnum || got != want) { + t.Errorf("%s = enum %t of %s, want %s", v.BaseName, v.IsEnum, got, want) + } + } + } + + want := map[string]string{ + "kind": "string nullable=false ref= ", + "name": "string nullable=false ref= ", + "size": "Size nullable=true ref=#/components/schemas/Size ", + "owner": "Owner nullable=true ref=#/components/schemas/Owner Who keeps the pet.", + "born": "Date nullable=true ref= ", + "nickname": "string nullable=true ref= ", + "tags": "Array nullable=true ref= ", + "alias": "string nullable=true ref=#/components/schemas/MaybeName ", + "mood": "string nullable=false ref= ", + "tag": "string nullable=true ref= ", + "expires": "Date | number nullable=true ref= ", + } + for name, w := range want { + if got[name] != w { + t.Errorf("%s = %q, want %q", name, got[name], w) + } + } + + ops, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + for _, op := range ops["default"] { + for _, param := range op.AllParams { + if param.BaseName == "q" && (param.DataType != "string" || !param.IsNullable) { + t.Errorf("q = %s nullable=%t, want a nullable string", param.DataType, param.IsNullable) + } + + if param.BaseName == "file" && !param.IsFile { + t.Errorf("file = %s, want a file", param.DataType) + } + } + } +} diff --git a/internal/parser/operation.go b/internal/parser/operation.go index 9872696..d8ec423 100644 --- a/internal/parser/operation.go +++ b/internal/parser/operation.go @@ -2,6 +2,7 @@ package parser import ( "sort" + "strconv" "strings" "github.com/getkin/kin-openapi/openapi3" @@ -224,8 +225,9 @@ func (p *Parser) operationToCodegen(path, method string, op *openapi3.Operation, Name: name, Scopes: make([]map[string]any, len(scopes)), } + // -last for {{^-last}}, which the mustache engine lacks. for i, scope := range scopes { - sec.Scopes[i] = map[string]any{"scope": scope} + sec.Scopes[i] = map[string]any{"scope": scope, "-last": i == len(scopes)-1} } co.AuthMethods = append(co.AuthMethods, sec) @@ -265,7 +267,7 @@ func (p *Parser) operationToCodegen(path, method string, op *openapi3.Operation, // CodegenParameter per property, appending them to the operation's FormParams and // AllParams. This mirrors upstream openapi-generator, which surfaces form fields (e.g. an // uploaded `file`) as individual parameters rather than a single opaque body. -func (p *Parser) addFormParams(co *codegen.CodegenOperation, schema *openapi3.Schema, contentType string) { +func (p *Parser) addFormParams(co *codegen.CodegenOperation, schema *openapi3.Schema, contentType string, encoding map[string]*openapi3.Encoding) { requiredSet := make(map[string]bool) for _, r := range schema.Required { requiredSet[r] = true @@ -306,11 +308,21 @@ func (p *Parser) addFormParams(co *codegen.CodegenOperation, schema *openapi3.Sc IsDateTime: prop.IsDateTime, IsDateTimeType: prop.IsDateTimeType, IsEnum: prop.IsEnum, + IsNullable: prop.IsNullable, Items: prop.Items, AllowableValues: prop.AllowableValues, EnumName: prop.EnumName, UniqueItems: prop.UniqueItems, + Vars: prop.Vars, } + + // The property's encoding with its defaults filled in: form style, + // exploded. go-fetch reads it for urlencoded and multipart bodies. + sm := encoding[name].SerializationMethod() + fp.Style = sm.Style + fp.IsExplode = sm.Explode + fp.IsDeepObject = sm.Style == openapi3.SerializationDeepObject + if fp.IsArray { fp.CollectionFormat = "csv" // An array of files must be sent as FormData with one append per @@ -342,39 +354,79 @@ func (p *Parser) addFormParams(co *codegen.CodegenOperation, schema *openapi3.Sc } } -// deduplicateParams removes duplicate parameters by name, keeping the last occurrence. -// This is useful when path-level and operation-level parameters have the same name. +// deduplicateParams drops a parameter that a later one of the same location +// and name replaces (an operation's over its path item's). A parameter whose +// ParamName another one already has (path item_id next to header Item-Id, +// query sort_by next to sortBy) is renamed itemId2, as upstream's +// ensureUniqueParams does, so that both stay and are sent. func deduplicateParams(params []*codegen.CodegenParameter) []*codegen.CodegenParameter { - if len(params) == 0 { - return params - } - seen := make(map[string]int) var result []*codegen.CodegenParameter - // Iterate through parameters and track positions - for i, param := range params { + for _, param := range params { if param == nil { continue } - key := param.ParamName - if idx, exists := seen[key]; exists { - // Replace previous occurrence + key := paramLocation(param) + " " + param.BaseName + if idx, ok := seen[key]; ok { result[idx] = param - } else { - // First occurrence - seen[key] = len(result) - result = append(result, param) + + continue + } + + seen[key] = len(result) + result = append(result, param) + } + + names := make(map[string]bool, len(result)) + + for _, param := range result { + for names[param.ParamName] { + param.ParamName = nextName(param.ParamName) } - _ = i // unused + names[param.ParamName] = true } return result } +// paramLocation is where a parameter goes: path, query, header, cookie, +// form or body. +func paramLocation(p *codegen.CodegenParameter) string { + switch { + case p.IsPathParam: + return "path" + case p.IsQueryParam: + return "query" + case p.IsHeaderParam: + return "header" + case p.IsCookieParam: + return "cookie" + case p.IsFormParam: + return "form" + } + + return "body" +} + +// nextName counts a name up: id becomes id2, id2 becomes id3. +func nextName(name string) string { + i := len(name) + for i > 0 && name[i-1] >= '0' && name[i-1] <= '9' { + i-- + } + + n, err := strconv.Atoi(name[i:]) + if err != nil { + return name + "2" + } + + return name[:i] + strconv.Itoa(n+1) +} + // requestBody turns the first content type of a request body, in sorted // order, into the operation's body parameter; form bodies expand into one // parameter per property instead. @@ -403,7 +455,7 @@ func (p *Parser) requestBody(co *codegen.CodegenOperation, body *openapi3.Reques isForm := strings.HasPrefix(contentType, "multipart/") || contentType == "application/x-www-form-urlencoded" if isForm && isObjectSchema(schema) && len(schema.Properties) > 0 { - p.addFormParams(co, schema, contentType) + p.addFormParams(co, schema, contentType, mediaType.Encoding) if strings.HasPrefix(contentType, "multipart/") { co.IsMultipart = true @@ -469,6 +521,10 @@ func (p *Parser) inlineBody(bodyParam *codegen.CodegenParameter, schema *openapi // bare type. Without this an array body renders as `Array` and an // undefined `ArrayToJSON` helper. prop := p.schemaToProperty("body", schema, required) + p.inlineVars(prop, schema) + p.inlineMembers(prop, schema) + bodyParam.Vars = prop.Vars + bodyParam.ComposedSchemas = prop.ComposedSchemas bodyParam.DataType = prop.DataType bodyParam.BaseType = prop.BaseType bodyParam.IsArray = prop.IsArray diff --git a/internal/parser/parameter.go b/internal/parser/parameter.go index 909c5a4..c68b347 100644 --- a/internal/parser/parameter.go +++ b/internal/parser/parameter.go @@ -81,9 +81,12 @@ func (p *Parser) parameterToCodegen(param *openapi3.Parameter) *codegen.CodegenP cp.IsModel = prop.IsModel cp.IsContainer = prop.IsContainer cp.Items = prop.Items + cp.Vars = prop.Vars cp.AllowableValues = prop.AllowableValues cp.EnumName = prop.EnumName cp.DatatypeWithEnum = prop.DatatypeWithEnum + cp.IsNullable = prop.IsNullable + cp.Ref = prop.Ref // Collection format if cp.IsArray { diff --git a/internal/parser/property.go b/internal/parser/property.go index c55fc56..2a96096 100644 --- a/internal/parser/property.go +++ b/internal/parser/property.go @@ -61,6 +61,16 @@ func (p *Parser) schemaRefToProperty(name string, ref *openapi3.SchemaRef, requi } prop := p.schemaToProperty(name, ref.Value, required) + if ref.Ref == "" { + p.inlineVars(prop, ref.Value) + p.inlineMembers(prop, ref.Value) + } + + // A $ref sets them; an `allOf: [$ref]` wrapper has them from its member. + if ref.Ref != "" { + prop.Ref = ref.Ref + prop.IsEnumRef = prop.IsEnum + } // Only object-like targets need the override: arrays, enums and primitives are // already typed correctly by schemaToProperty, and clobbering them with the @@ -83,6 +93,46 @@ func (p *Parser) schemaRefToProperty(name string, ref *openapi3.SchemaRef, requi return prop } +// inlineVars gives a property typed from an inline object schema, one with +// properties and no $ref, those properties as Vars, for a generator that +// declares a type for it. The TypeScript generators type it any and read none. +// An object that also takes additionalProperties gets none: a type of its +// properties alone would drop the other keys. +func (p *Parser) inlineVars(prop *codegen.CodegenProperty, schema *openapi3.Schema) { + extra := schema.AdditionalProperties.Schema != nil || + (schema.AdditionalProperties.Has != nil && *schema.AdditionalProperties.Has) + + if !isObjectSchema(schema) || len(schema.Properties) == 0 || extra || + len(schema.AllOf) > 0 || len(schema.OneOf) > 0 || len(schema.AnyOf) > 0 { + return + } + + for _, name := range p.orderedPropertyNames(schema) { + prop.Vars = append(prop.Vars, p.schemaRefToProperty(name, schema.Properties[name], slices.Contains(schema.Required, name))) + } +} + +// inlineMembers gives a property typed from an inline oneOf/anyOf of two or +// more members those members as ComposedSchemas, for a generator that +// declares a type for it. The TypeScript generators read the union from +// DataType instead. +func (p *Parser) inlineMembers(prop *codegen.CodegenProperty, schema *openapi3.Schema) { + if len(schema.OneOf)+len(schema.AnyOf) < 2 || len(schema.AllOf) > 0 || len(schema.Properties) > 0 { + return + } + + members := func(refs openapi3.SchemaRefs) []*codegen.CodegenProperty { + var out []*codegen.CodegenProperty + for _, ref := range refs { + out = append(out, p.schemaRefToProperty(prop.BaseName, ref, true)) + } + + return out + } + + prop.ComposedSchemas = &codegen.CodegenComposedSchemas{OneOf: members(schema.OneOf), AnyOf: members(schema.AnyOf)} +} + // applyMemberType resolves a single composition member ($ref or inline schema) // onto prop, copying its type-bearing fields. Returns false if the member could // not be resolved to a concrete (non-any) type, leaving prop untouched. @@ -106,6 +156,16 @@ func (p *Parser) applyMemberType(prop *codegen.CodegenProperty, ref *openapi3.Sc prop.Items = member.Items prop.AllowableValues = member.AllowableValues prop.EnumName = member.EnumName + prop.Ref = member.Ref + prop.IsEnumRef = member.IsEnumRef + + // A wrapper of an enum model (`allOf: [$ref]`, a nullable 3.1 $ref) is + // typed as that model, as upstream types it, not as a copy of its values. + if member.IsEnumRef { + name := p.toModelName(extractRefName(member.Ref)) + prop.DataType, prop.BaseType, prop.ComplexType, prop.DatatypeWithEnum = name, name, name, name + prop.IsEnum, prop.IsModel, prop.IsPrimitiveType = false, true, false + } return true } @@ -255,9 +315,10 @@ func (p *Parser) schemaToProperty(name string, schema *openapi3.Schema, required case schema.Items != nil && schema.Items.Ref != "": modelName := p.toModelName(extractRefName(schema.Items.Ref)) prop.Items = &codegen.CodegenProperty{ - DataType: modelName, - Datatype: modelName, - IsModel: true, + DataType: modelName, + Datatype: modelName, + IsModel: true, + IsNullable: schema.Items.Value != nil && schema.Items.Value.Nullable && !schema.Items.Value.Type.Is(openapi3.TypeArray), } prop.DataType = "Array<" + modelName + ">" prop.Datatype = "Array<" + modelName + ">" @@ -266,6 +327,8 @@ func (p *Parser) schemaToProperty(name string, schema *openapi3.Schema, required case schema.Items != nil && schema.Items.Value != nil: prop.Items = p.schemaToProperty(name+"Item", schema.Items.Value, false) + p.inlineVars(prop.Items, schema.Items.Value) + p.inlineMembers(prop.Items, schema.Items.Value) prop.DataType = "Array<" + prop.Items.DataType + ">" prop.Datatype = "Array<" + prop.Items.DataType + ">" diff --git a/internal/parser/response.go b/internal/parser/response.go index 5a9c2bb..dcddf64 100644 --- a/internal/parser/response.go +++ b/internal/parser/response.go @@ -1,6 +1,7 @@ package parser import ( + "maps" "sort" "strings" @@ -48,6 +49,10 @@ func (p *Parser) responseToCodegen(code string, resp *openapi3.Response) *codege for _, ct := range respContentTypes { mediaType := resp.Content[ct] if mediaType.Schema == nil { + if mediaType.ItemSchema != nil && cr.VendorExtensions["x-stream"] == nil { + p.streamItem(cr, ct, mediaType.ItemSchema) + } + continue } @@ -65,6 +70,8 @@ func (p *Parser) responseToCodegen(code string, resp *openapi3.Response) *codege } else if mediaType.Schema.Value != nil { schema := mediaType.Schema.Value prop := p.schemaToProperty("response", schema, false) + p.inlineVars(prop, schema) + p.inlineMembers(prop, schema) cr.DataType = prop.DataType cr.BaseType = prop.BaseType @@ -161,3 +168,29 @@ func ptrString(s *string) string { return *s } + +// streamItem records a response that streams its items (3.2's itemSchema, +// which FastAPI writes for a yield endpoint): x-stream is its media type, +// x-stream-item the property of one item, of a server-sent event its data's +// contentSchema, or else the data itself, a string when the event schema +// does not say. The response keeps no DataType, so a generator that does not +// read them sees one without a body. +func (p *Parser) streamItem(cr *codegen.CodegenResponse, mediaType string, item *openapi3.SchemaRef) { + if strings.HasPrefix(mediaType, "text/event-stream") && item.Value != nil { + item = item.Value.Properties["data"] + if item == nil { + item = openapi3.NewStringSchema().NewRef() + } else if item.Value != nil && item.Value.ContentSchema != nil { + item = item.Value.ContentSchema + } + } + + // convertExtensions hands out the document's own map: copy it. + cr.VendorExtensions = maps.Clone(cr.VendorExtensions) + if cr.VendorExtensions == nil { + cr.VendorExtensions = map[string]any{} + } + + cr.VendorExtensions["x-stream"] = mediaType + cr.VendorExtensions["x-stream-item"] = p.schemaRefToProperty("item", item, true) +} diff --git a/internal/parser/stream_test.go b/internal/parser/stream_test.go new file mode 100644 index 0000000..293cb77 --- /dev/null +++ b/internal/parser/stream_test.go @@ -0,0 +1,68 @@ +package parser + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/xseman/openapi-generator/internal/codegen" +) + +// TestStreamItems: a response with only an itemSchema validates in a 3.1 +// document, as FastAPI writes one, and records its item: of a server-sent +// event, its data's contentSchema. +func TestStreamItems(t *testing.T) { + p := NewParser() + if err := p.LoadFromFile(filepath.Join("..", "..", "testdata", "specs", "feature", "responses-stream.yaml")); err != nil { + t.Fatal(err) + } + + ops, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + want := map[string]string{ + "streamTicks": "application/jsonl Tick", "streamWords": "application/jsonl string", "chat": "text/event-stream union", "rawEvents": "text/event-stream string", + "pings": "text/event-stream string", "records": "application/json-seq Tick", + "maybeTicks": "text/event-stream Tick nullable", + } + + for _, op := range ops["default"] { + r := op.Responses[0] + mediaType, _ := r.VendorExtensions["x-stream"].(string) + item, _ := r.VendorExtensions["x-stream-item"].(*codegen.CodegenProperty) + + got := "none" + if item != nil { + got = mediaType + " " + item.DataType + if item.IsNullable { + got += " nullable" + } + + if item.ComposedSchemas != nil { + got = mediaType + " union" + } + } + + if got != want[op.OperationId] || op.ReturnType != "" { + t.Errorf("%s = %s, returns %q; want %s and no return type", op.OperationId, got, op.ReturnType, want[op.OperationId]) + } + } +} + +// TestOpenAPI32Operations: a 3.1 document may hold 3.2's itemSchema, but not +// the operations 3.2 adds, which no generator reads. +func TestOpenAPI32Operations(t *testing.T) { + err := NewParser().LoadFromData([]byte(`openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /search: + query: + operationId: search + responses: {"200": {description: ok}} +`)) + if err == nil || !strings.Contains(err.Error(), "path /search: query and additionalOperations are for OpenAPI >=3.2") { + t.Errorf("err = %v, want the query operation refused", err) + } +} diff --git a/internal/parser/swagger2_test.go b/internal/parser/swagger2_test.go new file mode 100644 index 0000000..2968a75 --- /dev/null +++ b/internal/parser/swagger2_test.go @@ -0,0 +1,71 @@ +package parser + +import "testing" + +// TestSwagger2TypelessDefinition: openapi2conv leaves an empty properties +// map on every schema, which must not make a typeless one an object (a map +// in go-fetch, which fails on any other JSON value). +func TestSwagger2TypelessDefinition(t *testing.T) { + spec := []byte(`{ + "swagger": "2.0", + "info": {"title": "t", "version": "1"}, + "paths": {}, + "definitions": {"JSON": {"description": "any valid JSON value"}} + }`) + + p := NewParser() + + p.SkipValidation = true + if err := p.LoadFromData(spec); err != nil { + t.Fatal(err) + } + + models, err := p.GetModels() + if err != nil { + t.Fatal(err) + } + + // As the same schema in 3.0: no type, not an object's "any". + if m := findModel(t, models, "JSON"); m.DataType != "" || m.HasVars { + t.Errorf("JSON = %q, vars %t; want no type, as in 3.0", m.DataType, m.HasVars) + } +} + +// TestSwagger2YAML: a Swagger 2.0 document in YAML is one too, unquoted +// status codes included; it was read as an OpenAPI 3 one without openapi. +func TestSwagger2YAML(t *testing.T) { + p := NewParser() + + p.SkipValidation = true + if err := p.LoadFromData([]byte(`swagger: "2.0" +info: {title: t, version: "1"} +paths: + /pets: + get: + operationId: listPets + responses: + 200: {description: ok, schema: {$ref: "#/definitions/Pet"}} +definitions: + Pet: {type: object, properties: {zebra: {type: string}, apple: {type: string}}} +`)); err != nil { + t.Fatal(err) + } + + models, err := p.GetModels() + if err != nil { + t.Fatal(err) + } + + if got := varNames(findModel(t, models, "Pet").Vars); len(got) != 2 || got[0] != "zebra" { + t.Errorf("Pet vars = %v, want [zebra apple]", got) + } + + ops, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + if op := ops["default"]; len(op) != 1 || op[0].ReturnType != "Pet" { + t.Errorf("operations = %v, want listPets returning Pet", op) + } +} diff --git a/internal/template/gofmt.go b/internal/template/gofmt.go new file mode 100644 index 0000000..3434394 --- /dev/null +++ b/internal/template/gofmt.go @@ -0,0 +1,96 @@ +package template + +import ( + "bytes" + "go/ast" + "go/format" + "go/parser" + "go/token" + "path" + "strconv" + "strings" +) + +// formatGo drops the imports src does not use and formats it with go/format, +// so a template can import every package it might need. +func formatGo(src []byte) ([]byte, error) { + fset := token.NewFileSet() + + file, err := parser.ParseFile(fset, "", src, parser.ParseComments) + if err != nil { + return nil, err + } + + used := make(map[string]bool) + + ast.Inspect(file, func(n ast.Node) bool { + if sel, ok := n.(*ast.SelectorExpr); ok { + if id, ok := sel.X.(*ast.Ident); ok { + used[id.Name] = true + } + } + + return true + }) + + var kept []string + + for _, imp := range file.Imports { + if !isUnusedImport(imp, used) { + line := imp.Path.Value + if imp.Name != nil { + line = imp.Name.Name + " " + line + } + + kept = append(kept, line) + } + } + + // Print the file without its import declarations, then put back a single + // block of the kept ones: removing specs in place leaves blank lines that + // gofmt reads as import groups. + decls := file.Decls[:0] + + for _, decl := range file.Decls { + if gen, ok := decl.(*ast.GenDecl); !ok || gen.Tok != token.IMPORT { + decls = append(decls, decl) + } + } + + file.Decls = decls + + var buf bytes.Buffer + if err := format.Node(&buf, fset, file); err != nil { + return nil, err + } + + out := buf.String() + + if len(kept) > 0 { + pkg := 0 + if !strings.HasPrefix(out, "package ") { + pkg = strings.Index(out, "\npackage ") + 1 + } + + end := pkg + strings.IndexByte(out[pkg:], '\n') + 1 + out = out[:end] + "\nimport (\n" + strings.Join(kept, "\n") + "\n)\n" + out[end:] + } + + return format.Source([]byte(out)) +} + +// isUnusedImport reports whether imp is a named or plain import no selector +// in the file refers to; blank and dot imports always stay. +func isUnusedImport(imp *ast.ImportSpec, used map[string]bool) bool { + importPath, err := strconv.Unquote(imp.Path.Value) + if err != nil { + return false + } + + name := path.Base(importPath) + if imp.Name != nil { + name = imp.Name.Name + } + + return name != "_" && name != "." && !used[name] +} diff --git a/internal/template/gofmt_test.go b/internal/template/gofmt_test.go new file mode 100644 index 0000000..d9290c7 --- /dev/null +++ b/internal/template/gofmt_test.go @@ -0,0 +1,77 @@ +package template + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFormatGo(t *testing.T) { + src := `// Code generated by openapi-generator. DO NOT EDIT. + +// Package x is a package used in a test. +package x + +import ( + "encoding/json" + "fmt" + str "strings" + _ "embed" + "net/http" +) + +func f() string { return fmt.Sprint(str.ToUpper("a")) } +` + + got, err := formatGo([]byte(src)) + if err != nil { + t.Fatal(err) + } + + want := `// Code generated by openapi-generator. DO NOT EDIT. + +// Package x is a package used in a test. +package x + +import ( + _ "embed" + "fmt" + str "strings" +) + +func f() string { return fmt.Sprint(str.ToUpper("a")) } +` + if string(got) != want { + t.Errorf("formatGo:\n%s\nwant:\n%s", got, want) + } +} + +func TestFormatGoDropsEveryImport(t *testing.T) { + got, err := formatGo([]byte("package x\n\nimport \"fmt\"\n\nconst a = 1\n")) + if err != nil { + t.Fatal(err) + } + + if want := "package x\n\nconst a = 1\n"; string(got) != want { + t.Errorf("formatGo = %q, want %q", got, want) + } +} + +func TestRenderToFileGoSyntaxError(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "x.mustache"), []byte("package x\n\nfunc {\n"), 0o600); err != nil { + t.Fatal(err) + } + + out := filepath.Join(dir, "x.go") + + err := NewEngine(dir).RenderToFile("x.mustache", map[string]any{}, out) + if err == nil || !strings.Contains(err.Error(), out) { + t.Fatalf("RenderToFile error = %v, want one naming %s", err, out) + } + + if data, _ := os.ReadFile(out); string(data) != "package x\n\nfunc {\n" { + t.Errorf("unformatted source not written: %q", data) + } +} diff --git a/internal/template/mustache.go b/internal/template/mustache.go index 0453347..3ef2df4 100644 --- a/internal/template/mustache.go +++ b/internal/template/mustache.go @@ -248,6 +248,18 @@ func (e *Engine) RenderToFile(templateName string, data any, outputPath string) result = normalizeTypeScript(result) } + // Go sources go through go/format. One that does not parse is still + // written, unformatted, so the error can be read against the file. + var formatErr error + + if strings.HasSuffix(outputPath, ".go") { + if formatted, err := formatGo([]byte(result)); err != nil { + formatErr = fmt.Errorf("failed to format %s: %w", outputPath, err) + } else { + result = string(formatted) + } + } + // Create output directory if needed dir := filepath.Dir(outputPath) if err := os.MkdirAll(dir, 0o755); err != nil { @@ -259,6 +271,10 @@ func (e *Engine) RenderToFile(templateName string, data any, outputPath string) return fmt.Errorf("failed to write file %s: %w", outputPath, err) } + if formatErr != nil { + return formatErr + } + if e.Verbose { fmt.Printf("[TEMPLATE] Generated: %s\n", outputPath) } diff --git a/samples/go-fetch/.gitignore b/samples/go-fetch/.gitignore new file mode 100644 index 0000000..b3c095e --- /dev/null +++ b/samples/go-fetch/.gitignore @@ -0,0 +1 @@ +petstore-expanded diff --git a/samples/go-fetch/README.md b/samples/go-fetch/README.md new file mode 100644 index 0000000..92665ad --- /dev/null +++ b/samples/go-fetch/README.md @@ -0,0 +1,29 @@ +# go-fetch + +Sample usage of the `go-fetch` generator. + +[Petstore Extended](https://github.com/OAI/OpenAPI-Specification) + +The spec is vendored as `petstore-expanded.yaml` and the generator options live +in `config.yaml`, so the sample regenerates byte-for-byte: + +```bash +make samples-go-fetch +``` + +Which is equivalent to: + +```bash +go run ./cmd/openapi-generator generate \ + -g go-fetch \ + -i samples/go-fetch/petstore-expanded.yaml \ + -p packageName=petstore \ + -p withGoMod=true \ + -p moduleName=example.com/petstore \ + -o samples/go-fetch/petstore-expanded +``` + +The output is a module of its own (`withGoMod`), so `go vet ./...` in +`samples/go-fetch/petstore-expanded` compiles it. + +Run `openapi-generator config-help go-fetch` for the full list of options. diff --git a/samples/go-fetch/config.yaml b/samples/go-fetch/config.yaml new file mode 100644 index 0000000..96460ae --- /dev/null +++ b/samples/go-fetch/config.yaml @@ -0,0 +1,9 @@ +generatorName: go-fetch +inputSpec: samples/go-fetch/petstore-expanded.yaml +outputDir: samples/go-fetch/petstore-expanded +skipValidateSpec: false +verbose: false +additionalProperties: + packageName: petstore + withGoMod: true + moduleName: example.com/petstore diff --git a/samples/go-fetch/petstore-expanded.yaml b/samples/go-fetch/petstore-expanded.yaml new file mode 100644 index 0000000..6a3aa1d --- /dev/null +++ b/samples/go-fetch/petstore-expanded.yaml @@ -0,0 +1,124 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +servers: + - url: http://petstore.swagger.io/api +paths: + /pets: + get: + summary: Returns all pets from the system that the user has access to + operationId: findPets + parameters: + - name: tags + in: query + required: false + schema: + type: array + items: + type: string + - name: limit + in: query + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: pet response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + summary: Creates a new pet in the store + operationId: addPet + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewPet' + responses: + '200': + description: pet response + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + /pets/{id}: + get: + summary: Returns a pet by id + operationId: findPetById + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: pet response + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + delete: + summary: deletes a single pet + operationId: deletePet + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int64 + responses: + '204': + description: pet deleted +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + NewPet: + type: object + required: + - name + properties: + name: + type: string + tag: + type: string + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/scripts/regress.sh b/scripts/regress.sh index 73131b0..49a2bfb 100755 --- a/scripts/regress.sh +++ b/scripts/regress.sh @@ -34,6 +34,7 @@ git archive "$ref" | tar -x -C "$tmp/src" declare -A flipped=( [typescript-fetch]="withPackageJson=true withInterfaces=true useSingleRequestParameter=false prefixParameterInterfaces=true withoutRuntimeChecks=true stringEnums=true validationAttributes=true" [dart-fetch]="useDartIoSender=false" + [go-fetch]="withGoMod=true" ) # run GEN OUT [ARGS...]: generate $spec with both builds into */OUT/$name. diff --git a/templates/dart-fetch/apis.mustache b/templates/dart-fetch/apis.mustache index cba3fae..a09a3aa 100644 --- a/templates/dart-fetch/apis.mustache +++ b/templates/dart-fetch/apis.mustache @@ -56,9 +56,16 @@ class {{classname}} extends BaseApi { if (apiKey != null) { final v = apiKey('{{keyParamName}}'); if (v.isNotEmpty) { -{{#isKeyInHeader}} headers['{{keyParamName}}'] = v; -{{/isKeyInHeader}}{{#isKeyInQuery}} queryParams['{{keyParamName}}'] = [v]; -{{/isKeyInQuery}} } +{{#isKeyInHeader}} + headers['{{keyParamName}}'] = v; +{{/isKeyInHeader}} +{{#isKeyInQuery}} + queryParams['{{keyParamName}}'] = [v]; +{{/isKeyInQuery}} +{{#isKeyInCookie}} + headers.update('Cookie', (c) => '$c; {{keyParamName}}=$v', ifAbsent: () => '{{keyParamName}}=$v'); +{{/isKeyInCookie}} + } } } {{/isApiKey}} diff --git a/templates/dart-fetch/modelPropertyFromJson.mustache b/templates/dart-fetch/modelPropertyFromJson.mustache index 7798f13..54942ed 100644 --- a/templates/dart-fetch/modelPropertyFromJson.mustache +++ b/templates/dart-fetch/modelPropertyFromJson.mustache @@ -1 +1 @@ -{{#isArray}}{{#items.isModel}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((e) => {{items.complexType}}.fromJson(e as Map)).toList(){{/items.isModel}}{{^items.isModel}}{{#items.isEnum}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((e) => {{items.complexType}}.fromJson(e)).toList(){{/items.isEnum}}{{^items.isEnum}}{{#items.isDateTime}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((e) => DateTime.parse(e as String)).toList(){{/items.isDateTime}}{{^items.isDateTime}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.cast<{{{items.dataType}}}>().toList(){{/items.isDateTime}}{{/items.isEnum}}{{/items.isModel}}{{/isArray}}{{^isArray}}{{#isMap}}{{#additionalProperties.isModel}}(json['{{baseName}}'] as Map{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((k, v) => MapEntry(k, {{additionalProperties.complexType}}.fromJson(v as Map))){{/additionalProperties.isModel}}{{^additionalProperties.isModel}}(json['{{baseName}}'] as Map{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.cast(){{/additionalProperties.isModel}}{{/isMap}}{{^isMap}}{{#isModel}}{{#required}}{{^isNullable}}{{complexType}}.fromJson(json['{{baseName}}'] as Map){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : {{complexType}}.fromJson(json['{{baseName}}'] as Map){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : {{complexType}}.fromJson(json['{{baseName}}'] as Map){{/required}}{{/isModel}}{{^isModel}}{{#isEnum}}{{#required}}{{^isNullable}}{{{datatypeWithEnum}}}.fromJson(json['{{baseName}}']){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : {{{datatypeWithEnum}}}.fromJson(json['{{baseName}}']){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : {{{datatypeWithEnum}}}.fromJson(json['{{baseName}}']){{/required}}{{/isEnum}}{{^isEnum}}{{#isDateTime}}{{#required}}{{^isNullable}}DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/required}}{{/isDateTime}}{{^isDateTime}}{{#isDate}}{{#required}}{{^isNullable}}DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/required}}{{/isDate}}{{^isDate}}json['{{baseName}}'] as {{{dataType}}}{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}{{/isDate}}{{/isDateTime}}{{/isEnum}}{{/isModel}}{{/isMap}}{{/isArray}} \ No newline at end of file +{{#isArray}}{{#items.isModel}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((e) => {{items.complexType}}.fromJson(e as Map)).toList(){{/items.isModel}}{{^items.isModel}}{{#items.isEnum}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((e) => {{items.complexType}}.fromJson(e)).toList(){{/items.isEnum}}{{^items.isEnum}}{{#items.isDateTime}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((e) => DateTime.parse(e as String)).toList(){{/items.isDateTime}}{{^items.isDateTime}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.cast<{{{items.dataType}}}>().toList(){{/items.isDateTime}}{{/items.isEnum}}{{/items.isModel}}{{/isArray}}{{^isArray}}{{#isMap}}{{#additionalProperties.isModel}}(json['{{baseName}}'] as Map{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((k, v) => MapEntry(k, {{additionalProperties.complexType}}.fromJson(v as Map))){{/additionalProperties.isModel}}{{^additionalProperties.isModel}}(json['{{baseName}}'] as Map{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.cast(){{/additionalProperties.isModel}}{{/isMap}}{{^isMap}}{{#isModel}}{{#required}}{{^isNullable}}{{complexType}}.fromJson(json['{{baseName}}']{{^isEnumRef}} as Map{{/isEnumRef}}){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : {{complexType}}.fromJson(json['{{baseName}}']{{^isEnumRef}} as Map{{/isEnumRef}}){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : {{complexType}}.fromJson(json['{{baseName}}']{{^isEnumRef}} as Map{{/isEnumRef}}){{/required}}{{/isModel}}{{^isModel}}{{#isEnum}}{{#required}}{{^isNullable}}{{{datatypeWithEnum}}}.fromJson(json['{{baseName}}']){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : {{{datatypeWithEnum}}}.fromJson(json['{{baseName}}']){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : {{{datatypeWithEnum}}}.fromJson(json['{{baseName}}']){{/required}}{{/isEnum}}{{^isEnum}}{{#isDateTime}}{{#required}}{{^isNullable}}DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/required}}{{/isDateTime}}{{^isDateTime}}{{#isDate}}{{#required}}{{^isNullable}}DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/required}}{{/isDate}}{{^isDate}}json['{{baseName}}'] as {{{dataType}}}{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}{{/isDate}}{{/isDateTime}}{{/isEnum}}{{/isModel}}{{/isMap}}{{/isArray}} \ No newline at end of file diff --git a/templates/embed.go b/templates/embed.go index 21705ff..39c678d 100644 --- a/templates/embed.go +++ b/templates/embed.go @@ -5,5 +5,5 @@ import "embed" // FS contains all embedded template files. // -//go:embed typescript-fetch/*.mustache typescript-fetch/*.md dart-fetch/*.mustache +//go:embed typescript-fetch/*.mustache typescript-fetch/*.md dart-fetch/*.mustache go-fetch/*.mustache go-fetch/*.md var FS embed.FS diff --git a/templates/go-fetch/README.md b/templates/go-fetch/README.md new file mode 100644 index 0000000..b536dd9 --- /dev/null +++ b/templates/go-fetch/README.md @@ -0,0 +1,125 @@ +# go-fetch + +Generates a Go client in the shape of typescript-fetch: an API type per tag, +three methods per operation. The output imports only the standard library +and needs Go 1.24 (`omitzero`). + +## Usage + +```bash +openapi-generator generate -g go-fetch -i openapi.yaml -o ./petstore -p packageName=petstore +``` + +| Option | Default | Description | +| ------------- | ------------- | -------------------------------------------- | +| `packageName` | `openapi` | Go package name of the generated files | +| `withGoMod` | `false` | write a `go.mod`, making the output a module | +| `moduleName` | `packageName` | module path in `go.mod` | + +## Output + +| File | Contents | +| ----------------- | ---------------------------------------------------------------- | +| `runtime.go` | `Configuration`, `ResponseError`, `Opt`, `Date`, `File`, helpers | +| `api_.go` | `API`, a `Params` per operation, its methods | +| `model_.go` | one model | +| `README.md` | usage of the package | +| `go.mod` | with `withGoMod=true` | + +## The client + +```go +cfg := &petstore.Configuration{ + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + AccessToken: func(ctx context.Context, op, name string, scopes []string) (string, error) { return token, nil }, +} +pets := petstore.NewDefaultAPI(cfg) + +pet, err := pets.FindPetByID(ctx, petstore.FindPetByIDParams{ID: 1}) + +var rerr *petstore.ResponseError +if errors.As(err, &rerr) { + if e, ok := rerr.Model.(*petstore.Error); ok { + log.Print(e.Message) + } +} +``` + +| Method | Does | +| ------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `Xxx` | sends and decodes the body (JSON, text for a string or for an `any` the server does not send as JSON, `[]byte` for binary) | +| `XxxRaw` | sends; returns a 2xx `*http.Response` with its body unread | +| `XxxRequest` | builds the `*http.Request` without sending it | + +When a 2xx status has no body next to one that has (a 204 beside a 200), or +the result is nullable, the result is a pointer, nil for the bodiless status +or `null`, unless the type is nil-able already or a string. Each method +takes trailing `RequestEditorFn`s, which change the request after it is +built. A status outside 2xx is a `*ResponseError`; its `Model` is the body +decoded into the type the spec gives for that status (the exact code, then +`4XX`/`5XX`, then `default`), nil when the body lacks a property that type +requires. So is a 2xx whose body is of another type than the result (a 202 +`Problem` next to a 201 `Order`): `Xxx` returns it as a `*ResponseError` +with that body in `Model`, `XxxRaw` as the response. + +A response that streams its items, described by 3.2's `itemSchema` (FastAPI +writes it for a `yield` endpoint, in a 3.1 document), makes `Xxx` return an +`iter.Seq2[T, error]` that yields each item as it arrives: a JSON value per +line for JSON Lines, a `ServerSentEvent[T]` (`Data`, `Event`, `ID`, `Retry`) +per event for `text/event-stream`, `T` being the `contentSchema` of its +`data`, or the text itself. A range stops at the first error. The response +stays open until the range ends, a `break` or `ctx` is cancelled: range the +result, or cancel `ctx`. JSON Lines (`application/jsonl`, +`application/x-ndjson`) and `text/event-stream` are read; another streamed +media type has only `XxxRaw`. + +```go +events, err := api.Chat(ctx, petstore.ChatParams{Body: petstore.ChatBody{Prompt: "hi"}}) +if err != nil { + return err +} + +for e, err := range events { + if err != nil { + return err + } + + fmt.Println(e.Event, e.Data) +} +``` + +| `Configuration` | Use | +| --------------- | ---------------------------------------------------------------------- | +| `BasePath` | replaces the spec's first server URL | +| `HTTPClient` | any `Doer`; nil is `http.DefaultClient` | +| `Headers` | added to every request | +| `Username` | with `Password`, HTTP basic auth | +| `APIKey` | returns the key named `name` for operation `op` | +| `AccessToken` | returns the bearer token for scheme `name`, operation `op`, its scopes | + +## Types + +| Schema | Go | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| required property | `T` | +| optional (or read-only) | `*T`; slices, maps and `any` stay as they are: nil is absent, empty is sent | +| required and nullable | `*T`, sent as `null` when nil | +| optional and nullable | `Opt[T]`: absent, `Null[T]()` or `Some(v)` | +| `date` / `date-time` | `Date` / `DateTime` (a `time.Time`; one without an offset, a naive FastAPI datetime, is `Naive` and goes back without one) | +| enum | a named type with a constant per value; `any` when the values are of more than one JSON type | +| `byte` / `binary` property | `[]byte` (base64 on the wire) / `string` (the raw text, as pydantic sends `bytes`) | +| inline object | a struct named after its owner: `ProfileUser`, `SearchReposResponse`, `CreateCustomerAddress` | +| nullable item or result | `[]*T`, `map[string]*T`; a result of a nullable model is `*T` | +| `allOf` | one struct with the members' properties | +| `additionalProperties` next to properties | an `AdditionalProperties` map (numbered on a clash) beside the fields, read and written with them | +| `oneOf` / `anyOf` | a wrapper over the raw JSON, `AsX` / `FromX`; `AsX` refuses a value without X's required properties; inline, named after its owner as an object is | +| ... with a discriminator | also `Discriminator()` and `ValueByDiscriminator()` | +| ... pinning a property | a variant property of one value (`enum: [cat]`, 3.1 `const`) is set by `FromX` and checked by `AsX`; one that tells every variant apart is the discriminator, inline too | +| file in a multipart body | `File{Name, Body}`; an `octet-stream` body is `io.Reader` | +| other multipart field | its own part: a list of scalars a part per item (comma-joined with `explode: false`), an object as JSON | +| urlencoded form field | sent as its `encoding` says: form and exploded by default, `deepObject` key by key (`a[b][0]=x`); a form with a file goes as multipart | + +Not covered: server code, XML, server variables, validation, a discriminator +without `oneOf` (the parent stays a plain struct), `additionalProperties` +next to the properties of an inline object (it stays a map) or inherited +through `allOf` (the extra keys are dropped). diff --git a/templates/go-fetch/README.mustache b/templates/go-fetch/README.mustache new file mode 100644 index 0000000..2a28566 --- /dev/null +++ b/templates/go-fetch/README.mustache @@ -0,0 +1,21 @@ +# {{appName}} + +Go client for {{appName}} {{version}}, generated by openapi-generator +(go-fetch). It imports only the standard library and needs Go 1.24. + +```go +cfg := &{{packageName}}.Configuration{ + HTTPClient: &http.Client{Timeout: 10 * time.Second}, +} +``` + +Every operation has three methods on the API type of its tag: + +| Method | Returns | +| ------------ | ------------------------------------------------- | +| `Xxx` | the decoded body | +| `XxxRaw` | the `*http.Response` of a 2xx status, body unread | +| `XxxRequest` | the `*http.Request`, not sent | + +A status outside 2xx is a `*{{packageName}}.ResponseError`; its `Model` holds +the body decoded into the type the spec gives for that status. diff --git a/templates/go-fetch/api.mustache b/templates/go-fetch/api.mustache new file mode 100644 index 0000000..6900081 --- /dev/null +++ b/templates/go-fetch/api.mustache @@ -0,0 +1,277 @@ +// Code generated by openapi-generator. DO NOT EDIT. + +package {{packageName}} + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "iter" + "mime/multipart" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// {{classname}} sends the operations of one tag. +type {{classname}} struct { + cfg *Configuration +} + +// New{{classname}} returns a {{classname}} sending through cfg; nil is the +// zero Configuration. +func New{{classname}}(cfg *Configuration) *{{classname}} { + if cfg == nil { + cfg = &Configuration{} + } + + return &{{classname}}{cfg: cfg} +} +{{#operation}} +{{#vendorExtensions}} +{{#goDeclareParams}} +{{#x-go-inline-enums}} + +{{#doc}} +{{{.}}} +{{/doc}} +type {{name}} {{base}} + +const ( +{{#values}} + {{name}} {{type}} = {{{literal}}} +{{/values}} +) +{{/x-go-inline-enums}} +{{#x-go-inline}} + +{{#union}} +{{>union}} +{{/union}} +{{^union}} +{{#doc}} +{{{.}}} +{{/doc}} +type {{name}} struct { +{{#fields}} +{{#doc}} + {{{.}}} +{{/doc}} + {{name}} {{{type}}} {{{tag}}} +{{/fields}} +} +{{/union}} +{{/x-go-inline}} +{{#x-go-has-params}} + +// {{x-go-params}} holds the parameters of {{x-go-name}}. +type {{x-go-params}} struct { +{{#x-go-fields}} +{{#doc}} + {{{.}}} +{{/doc}} + {{name}} {{{type}}} +{{/x-go-fields}} +} +{{/x-go-has-params}} +{{/goDeclareParams}} + +// {{x-go-name}}Request builds the request {{x-go-name}} sends. +func (a *{{classname}}) {{x-go-name}}Request(ctx context.Context{{#x-go-has-params}}, p {{x-go-params}}{{/x-go-has-params}}, editors ...RequestEditorFn) (*http.Request, error) { + path := {{{x-go-path}}} +{{#x-go-in-path}} + + if err := expandPath(&path, {{{placeholder}}}, p.{{field}}, {{{quoted}}}, "{{style}}", {{explode}}); err != nil { + return nil, err + } +{{/x-go-in-path}} + + query := url.Values{} +{{#x-go-in-query}} + + if err := addQuery(query, {{{quoted}}}, p.{{field}}, "{{style}}", {{explode}}); err != nil { + return nil, err + } +{{/x-go-in-query}} +{{#x-go-body-json}} + + body, err := jsonBody(p.{{field}}, {{null}}) + if err != nil { + return nil, err + } +{{/x-go-body-json}} +{{#x-go-body-text}} + + body := textBody(p.{{.}}) +{{/x-go-body-text}} +{{#x-go-body-reader}} + + body := p.{{.}} +{{/x-go-body-reader}} +{{#x-go-body-form}} + + form := url.Values{} +{{#x-go-in-form}} + + if err := addQuery(form, {{{quoted}}}, p.{{field}}, "{{style}}", {{explode}}); err != nil { + return nil, err + } +{{/x-go-in-form}} + + body := strings.NewReader(form.Encode()) +{{/x-go-body-form}} +{{#x-go-body-multipart}} + + body := new(bytes.Buffer) + mw := multipart.NewWriter(body) +{{#x-go-in-form}} + + if err := addPart(mw, {{{quoted}}}, p.{{field}}, {{explode}}); err != nil { + return nil, err + } +{{/x-go-in-form}} + + if err := mw.Close(); err != nil { + return nil, err + } +{{/x-go-body-multipart}} + + req, err := a.cfg.newRequest(ctx, {{{x-go-method}}}, path, query, {{#x-go-has-body}}body{{/x-go-has-body}}{{^x-go-has-body}}nil{{/x-go-has-body}}) + if err != nil { + return nil, err + } +{{#x-go-content-type}} + + req.Header.Set("Content-Type", {{{.}}}) +{{/x-go-content-type}} +{{#x-go-body-form}} + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") +{{/x-go-body-form}} +{{#x-go-body-multipart}} + + req.Header.Set("Content-Type", mw.FormDataContentType()) +{{/x-go-body-multipart}} +{{#x-go-in-header}} + + if err := setHeader(req, {{{quoted}}}, p.{{field}}, {{explode}}); err != nil { + return nil, err + } +{{/x-go-in-header}} +{{#x-go-in-cookie}} + + if err := addCookie(req, {{{quoted}}}, p.{{field}}, {{explode}}); err != nil { + return nil, err + } +{{/x-go-in-cookie}} +{{#x-go-auth}} +{{#apiKey}} + + if err := a.cfg.apiKey(ctx, req, {{{op}}}, {{{key}}}, "{{in}}"); err != nil { + return nil, err + } +{{/apiKey}} +{{#basic}} + + a.cfg.basicAuth(req) +{{/basic}} +{{#bearer}} + + if err := a.cfg.bearer(ctx, req, {{{op}}}, {{{scheme}}}, {{{scopes}}}); err != nil { + return nil, err + } +{{/bearer}} +{{/x-go-auth}} + + return edit(ctx, req, editors) +} + +// {{x-go-name}}Raw sends {{x-go-name}} and returns a 2xx response with its body +// unread, for the caller to close; any other status is a *ResponseError. +func (a *{{classname}}) {{x-go-name}}Raw(ctx context.Context{{#x-go-has-params}}, p {{x-go-params}}{{/x-go-has-params}}, editors ...RequestEditorFn) (*http.Response, error) { + req, err := a.{{x-go-name}}Request(ctx{{#x-go-has-params}}, p{{/x-go-has-params}}, editors...) + if err != nil { + return nil, err + } +{{#x-go-errors}} + + return a.cfg.send(req, func(status int) (any, []string) { +{{#hasCodes}} + switch status { +{{#codes}} + case {{code}}: + return new({{{type}}}), {{{keys}}} +{{/codes}} + } + +{{/hasCodes}} +{{#hasRanges}} + switch status / 100 { +{{#ranges}} + case {{digit}}: + return new({{{type}}}), {{{keys}}} +{{/ranges}} + } + +{{/hasRanges}} +{{#default}} + return new({{{type}}}), {{{keys}}} +{{/default}} +{{^default}} + return nil, nil +{{/default}} + }) +{{/x-go-errors}} +{{^x-go-errors}} + + return a.cfg.send(req, nil) +{{/x-go-errors}} +} + +{{#x-go-doc}} +{{{.}}} +{{/x-go-doc}} +{{#x-go-decode-json}} +func (a *{{classname}}) {{x-go-name}}(ctx context.Context{{#x-go-has-params}}, p {{x-go-params}}{{/x-go-has-params}}, editors ...RequestEditorFn) ({{{.}}}, error) { +{{#x-go-other-2xx}} + resp, err := a.{{x-go-name}}Raw(ctx{{#x-go-has-params}}, p{{/x-go-has-params}}, editors...) + resp, err = other2xx(resp, err, func(status int) (any, []string) { + switch status { +{{#codes}} + case {{code}}: + return new({{{type}}}), {{{keys}}} +{{/codes}} + } + + return nil, nil + }) + + return decodeJSON[{{{x-go-type}}}](resp, err) +{{/x-go-other-2xx}} +{{^x-go-other-2xx}} + return decodeJSON[{{{.}}}](a.{{x-go-name}}Raw(ctx{{#x-go-has-params}}, p{{/x-go-has-params}}, editors...)) +{{/x-go-other-2xx}} +} +{{/x-go-decode-json}} +{{#x-go-decode-bytes}} +func (a *{{classname}}) {{x-go-name}}(ctx context.Context{{#x-go-has-params}}, p {{x-go-params}}{{/x-go-has-params}}, editors ...RequestEditorFn) ([]byte, error) { + return decodeBytes(a.{{x-go-name}}Raw(ctx{{#x-go-has-params}}, p{{/x-go-has-params}}, editors...)) +} +{{/x-go-decode-bytes}} +{{#x-go-stream}} +func (a *{{classname}}) {{x-go-name}}(ctx context.Context{{#x-go-has-params}}, p {{x-go-params}}{{/x-go-has-params}}, editors ...RequestEditorFn) (iter.Seq2[{{{item}}}, error], error) { + return {{{decode}}}(a.{{x-go-name}}Raw(ctx{{#x-go-has-params}}, p{{/x-go-has-params}}, editors...)) +} +{{/x-go-stream}} +{{#x-go-discard}} +func (a *{{classname}}) {{x-go-name}}(ctx context.Context{{#x-go-has-params}}, p {{x-go-params}}{{/x-go-has-params}}, editors ...RequestEditorFn) error { + return discard(a.{{x-go-name}}Raw(ctx{{#x-go-has-params}}, p{{/x-go-has-params}}, editors...)) +} +{{/x-go-discard}} +{{/vendorExtensions}} +{{/operation}} diff --git a/templates/go-fetch/gomod.mustache b/templates/go-fetch/gomod.mustache new file mode 100644 index 0000000..c939d81 --- /dev/null +++ b/templates/go-fetch/gomod.mustache @@ -0,0 +1,3 @@ +module {{moduleName}} + +go 1.24 diff --git a/templates/go-fetch/model.mustache b/templates/go-fetch/model.mustache new file mode 100644 index 0000000..637db08 --- /dev/null +++ b/templates/go-fetch/model.mustache @@ -0,0 +1,94 @@ +// Code generated by openapi-generator. DO NOT EDIT. + +package {{packageName}} + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "time" +) + +{{#vendorExtensions.x-go-enums}} +{{#doc}} +{{{.}}} +{{/doc}} +type {{name}} {{base}} + +const ( +{{#values}} + {{name}} {{type}} = {{{literal}}} +{{/values}} +) + +{{/vendorExtensions.x-go-enums}} +{{#vendorExtensions.x-go-struct}} +{{#vendorExtensions.x-go-doc}} +{{{.}}} +{{/vendorExtensions.x-go-doc}} +type {{classname}} struct { +{{#vendorExtensions.x-go-fields}} +{{#doc}} + {{{.}}} +{{/doc}} + {{name}} {{{type}}} {{{tag}}} +{{/vendorExtensions.x-go-fields}} +} +{{#vendorExtensions.x-go-extra}} + +// MarshalJSON writes the declared fields and the keys of {{field}}. +func (m {{classname}}) MarshalJSON() ([]byte, error) { + type plain {{classname}} + + b, err := json.Marshal(plain(m)) + if err != nil { + return nil, err + } + + return withExtra(b, m.{{field}}, {{{declared}}}) +} + +// UnmarshalJSON reads the declared fields, and the other keys into {{field}}. +func (m *{{classname}}) UnmarshalJSON(b []byte) error { + type plain {{classname}} + + if err := json.Unmarshal(b, (*plain)(m)); err != nil { + return err + } + + extra, err := extraKeys[{{{value}}}](b, {{{declared}}}) + m.{{field}} = extra + + return err +} +{{/vendorExtensions.x-go-extra}} +{{#vendorExtensions.x-go-inline}} + +{{#union}} +{{>union}} +{{/union}} +{{^union}} +{{#doc}} +{{{.}}} +{{/doc}} +type {{name}} struct { +{{#fields}} +{{#doc}} + {{{.}}} +{{/doc}} + {{name}} {{{type}}} {{{tag}}} +{{/fields}} +} +{{/union}} +{{/vendorExtensions.x-go-inline}} +{{/vendorExtensions.x-go-struct}} +{{#vendorExtensions.x-go-alias}} +{{#vendorExtensions.x-go-doc}} +{{{.}}} +{{/vendorExtensions.x-go-doc}} +type {{classname}} = {{{vendorExtensions.x-go-alias-type}}} +{{/vendorExtensions.x-go-alias}} +{{#vendorExtensions.x-go-union}} +{{>union}} +{{/vendorExtensions.x-go-union}} diff --git a/templates/go-fetch/runtime.mustache b/templates/go-fetch/runtime.mustache new file mode 100644 index 0000000..7467573 --- /dev/null +++ b/templates/go-fetch/runtime.mustache @@ -0,0 +1,1051 @@ +// Code generated by openapi-generator. DO NOT EDIT. + +package {{packageName}} + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "iter" + "maps" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "reflect" + "regexp" + "slices" + "strconv" + "strings" + "time" +) + +// BasePath is the server URL the spec lists first. +const BasePath = "{{{basePath}}}" + +// Doer sends an HTTP request; *http.Client is one. +type Doer interface { + Do(req *http.Request) (*http.Response, error) +} + +// RequestEditorFn changes a request before it is sent. +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Configuration is what every API type shares. The zero value is usable: it +// sends to BasePath through http.DefaultClient without credentials. +type Configuration struct { + // BasePath replaces the package's BasePath when set. + BasePath string + // HTTPClient sends the requests; nil uses http.DefaultClient. + HTTPClient Doer + // Headers are added to every request. + Headers http.Header + // Username and Password are sent as HTTP basic authentication to an + // operation that takes it. + Username string + Password string + // APIKey returns the key named name for the operation op; an empty key + // is not sent. + APIKey func(ctx context.Context, op, name string) (string, error) + // AccessToken returns the bearer token of the security scheme name for + // the operation op and its scopes; an empty token is not sent. + AccessToken func(ctx context.Context, op, name string, scopes []string) (string, error) +} + +// ResponseError is the error an operation returns for a status outside 2xx, +// and the typed method for a 2xx whose body is not the result's type. +type ResponseError struct { + StatusCode int + Header http.Header + Body []byte + // Model is Body decoded into the type the spec gives for the status + // (the exact code, then 4XX/5XX, then default), as a pointer; nil when + // the spec gives none, or the body does not decode or lacks a property + // the type requires. + Model any +} + +// Error returns the status, "404 Not Found". +func (e *ResponseError) Error() string { + return strconv.Itoa(e.StatusCode) + " " + http.StatusText(e.StatusCode) +} + +// Opt is a property that can be absent, null or a value. The zero value is +// absent; omitzero leaves it out of the JSON. +type Opt[T any] struct { + Value T + // Set is false when the property is absent. + Set bool + // Null is true when the property is an explicit null. + Null bool +} + +// Some returns an Opt holding v. +func Some[T any](v T) Opt[T] { return Opt[T]{Value: v, Set: true} } + +// Null returns an Opt holding an explicit null. +func Null[T any]() Opt[T] { return Opt[T]{Set: true, Null: true} } + +// IsZero reports whether o is absent. +func (o Opt[T]) IsZero() bool { return !o.Set } + +// MarshalJSON encodes o's value, or null. +func (o Opt[T]) MarshalJSON() ([]byte, error) { + if o.Null || !o.Set { + return []byte("null"), nil + } + + return json.Marshal(o.Value) +} + +// UnmarshalJSON decodes a present property, null included. +func (o *Opt[T]) UnmarshalJSON(b []byte) error { + *o = Opt[T]{Set: true, Null: string(b) == "null"} + if o.Null { + return nil + } + + return json.Unmarshal(b, &o.Value) +} + +// Ptr returns a pointer to v, for optional fields. +func Ptr[T any](v T) *T { return &v } + +// DateTime is a date-time, RFC 3339 in JSON and parameters. It also reads a +// time without an offset, which servers send for a naive datetime (FastAPI +// over a database column without a zone); that one stays Naive, its clock +// in UTC, and goes back without an offset, as received. +type DateTime struct { + time.Time + Naive bool +} + +// naiveLayout is a date-time without an offset, to the microsecond as +// Python writes it. +const naiveLayout = "2006-01-02T15:04:05.000000" + +// String returns d in RFC 3339, or without an offset when d is Naive. +func (d DateTime) String() string { + switch { + case !d.Naive: + return d.Format(time.RFC3339Nano) + case d.Nanosecond() == 0: + return d.Format("2006-01-02T15:04:05") + case d.Nanosecond()%1000 == 0: + return d.Format(naiveLayout) + } + + return d.Format("2006-01-02T15:04:05.999999999") +} + +// MarshalJSON encodes d as String does. +func (d DateTime) MarshalJSON() ([]byte, error) { return []byte(`"` + d.String() + `"`), nil } + +// UnmarshalJSON decodes an RFC 3339 string, or one without an offset as a +// Naive time; null leaves d as it is. +func (d *DateTime) UnmarshalJSON(b []byte) error { + if string(b) == "null" { + return nil + } + + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + *d = DateTime{Time: t} + + return nil + } + + t, err := time.Parse("2006-01-02T15:04:05.999999999", s) + if err != nil { + return err + } + + *d = DateTime{Time: t, Naive: true} + + return nil +} + +// Date is a calendar date, "2006-01-02" in JSON and parameters. +type Date struct{ time.Time } + +// String returns d as "2006-01-02". +func (d Date) String() string { return d.Format(time.DateOnly) } + +// MarshalJSON encodes d as "2006-01-02". +func (d Date) MarshalJSON() ([]byte, error) { return []byte(`"` + d.String() + `"`), nil } + +// UnmarshalJSON decodes a "2006-01-02" string; null leaves d as it is. +func (d *Date) UnmarshalJSON(b []byte) error { + if string(b) == "null" { + return nil + } + + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + + t, err := time.Parse(time.DateOnly, s) + d.Time = t + + return err +} + +// File is a file part of a multipart request. +type File struct { + Name string + Body io.Reader +} + +func (c *Configuration) newRequest(ctx context.Context, method, path string, query url.Values, body io.Reader) (*http.Request, error) { + base := BasePath + if c.BasePath != "" { + base = c.BasePath + } + + u := strings.TrimRight(base, "/") + path + if len(query) > 0 { + u += "?" + query.Encode() + } + + req, err := http.NewRequestWithContext(ctx, method, u, body) + if err != nil { + return nil, err + } + + for k, vs := range c.Headers { + for _, v := range vs { + req.Header.Add(k, v) + } + } + + return req, nil +} + +func (c *Configuration) apiKey(ctx context.Context, req *http.Request, op, name, in string) error { + if c.APIKey == nil { + return nil + } + + key, err := c.APIKey(ctx, op, name) + if err != nil || key == "" { + return err + } + + switch in { + case "query": + q := req.URL.Query() + q.Set(name, key) + req.URL.RawQuery = q.Encode() + case "cookie": + req.AddCookie(&http.Cookie{Name: name, Value: key}) + default: + req.Header.Set(name, key) + } + + return nil +} + +func (c *Configuration) basicAuth(req *http.Request) { + if c.Username != "" || c.Password != "" { + req.SetBasicAuth(c.Username, c.Password) + } +} + +func (c *Configuration) bearer(ctx context.Context, req *http.Request, op, name string, scopes []string) error { + if c.AccessToken == nil { + return nil + } + + token, err := c.AccessToken(ctx, op, name, scopes) + if err != nil || token == "" { + return err + } + + req.Header.Set("Authorization", "Bearer "+token) + + return nil +} + +func edit(ctx context.Context, req *http.Request, editors []RequestEditorFn) (*http.Request, error) { + for _, e := range editors { + if err := e(ctx, req); err != nil { + return nil, err + } + } + + return req, nil +} + +// send sends req and returns a 2xx response unread; any other status is a +// *ResponseError, its body decoded into model(status) when model gives one +// and the body holds the keys it names. +func (c *Configuration) send(req *http.Request, model func(status int) (any, []string)) (*http.Response, error) { + var doer Doer = http.DefaultClient + if c.HTTPClient != nil { + doer = c.HTTPClient + } + + resp, err := doer.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return resp, nil + } + + return nil, responseError(resp, model) +} + +// responseError reads and closes resp's body into a *ResponseError, the body +// decoded into model(status) when model gives one and the body holds the +// keys it names. +func responseError(resp *http.Response, model func(status int) (any, []string)) error { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + rerr := &ResponseError{StatusCode: resp.StatusCode, Header: resp.Header, Body: body} + if model != nil { + m, keys := model(resp.StatusCode) + if m != nil && (len(keys) == 0 || requireKeys(body, keys...) == nil) && json.Unmarshal(body, m) == nil { + rerr.Model = m + } + } + + return rerr +} + +// withExtra adds the keys of extra that are not declared to the JSON +// object b, in key order. +func withExtra[V any](b []byte, extra map[string]V, declared ...string) ([]byte, error) { + for _, k := range slices.Sorted(maps.Keys(extra)) { + if slices.Contains(declared, k) { + continue + } + + key, err := json.Marshal(k) + if err != nil { + return nil, err + } + + value, err := json.Marshal(extra[k]) + if err != nil { + return nil, err + } + + if len(b) > 2 { + b = append(b[:len(b)-1], ',') + } else { + b = b[:len(b)-1] + } + + b = append(append(append(append(b, key...), ':'), value...), '}') + } + + return b, nil +} + +// extraKeys decodes the keys of the JSON object b that are not declared, +// nil when there are none. +func extraKeys[V any](b []byte, declared ...string) (map[string]V, error) { + var all map[string]json.RawMessage + if err := json.Unmarshal(b, &all); err != nil { + return nil, err + } + + var extra map[string]V + + for k, raw := range all { + if slices.Contains(declared, k) { + continue + } + + var v V + if err := json.Unmarshal(raw, &v); err != nil { + return nil, err + } + + if extra == nil { + extra = make(map[string]V) + } + + extra[k] = v + } + + return extra, nil +} + +// other2xx passes resp on, unless model gives a type for its 2xx status: +// then resp is a *ResponseError holding that body, which the typed result +// cannot. +func other2xx(resp *http.Response, err error, model func(status int) (any, []string)) (*http.Response, error) { + if err != nil { + return nil, err + } + + if m, _ := model(resp.StatusCode); m == nil { + return resp, nil + } + + return nil, responseError(resp, model) +} + +var jsonMime = regexp.MustCompile(`(?i)^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$`) + +// decodeJSON reads and closes resp's body into a T: JSON, or the text itself +// for a string, or an any, the server does not send as JSON (an any typed +// only by an empty schema next to a text media type). An empty body is T's +// zero, nil for a pointer. +func decodeJSON[T any](resp *http.Response, err error) (T, error) { + var out T + if err != nil { + return out, err + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil || len(body) == 0 { + return out, err + } + + if ct := resp.Header.Get("Content-Type"); !jsonMime.MatchString(ct) { + switch p := any(&out).(type) { + case *string: + *p = string(body) + + return out, nil + case *any: + if ct != "" { + *p = string(body) + + return out, nil + } + } + } + + err = json.Unmarshal(body, &out) + + return out, err +} + +// requireKeys returns an error when the JSON object b lacks one of keys. +func requireKeys(b []byte, keys ...string) error { + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + return err + } + + for _, k := range keys { + if _, ok := m[k]; !ok { + return fmt.Errorf("missing required property %q", k) + } + } + + return nil +} + +// decodeBytes reads and closes resp's body. +func decodeBytes(resp *http.Response, err error) ([]byte, error) { + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + return io.ReadAll(resp.Body) +} + +// ServerSentEvent is a server-sent event: its data as a T (the text itself for a +// string), and its name, id and retry (milliseconds) when the server sets them. +type ServerSentEvent[T any] struct { + Data T `json:"data"` + Event string `json:"event,omitempty"` + ID string `json:"id,omitempty"` + Retry int `json:"retry,omitempty"` +} + +// decodeStream yields the JSON values of resp's body (JSON Lines) as they +// arrive, then closes it. A range over the result ends at the first error; +// one that stops early closes the body too. +func decodeStream[T any](resp *http.Response, err error) (iter.Seq2[T, error], error) { + if err != nil { + return nil, err + } + + return func(yield func(T, error) bool) { + defer resp.Body.Close() + + dec := json.NewDecoder(resp.Body) + + for { + var v T + + err := dec.Decode(&v) + if errors.Is(err, io.EOF) || !yield(v, err) || err != nil { + return + } + } + }, nil +} + +// decodeEvents yields the server-sent events of resp's body as they arrive, +// then closes it, as decodeStream does. An event ends at a blank line, one +// the body ends before is dropped; several data lines are its data joined by +// newlines, a comment line (a keep-alive ": ping") is skipped, and an id +// holds until the next one. +func decodeEvents[T any](resp *http.Response, err error) (iter.Seq2[ServerSentEvent[T], error], error) { + if err != nil { + return nil, err + } + + return func(yield func(ServerSentEvent[T], error) bool) { + defer resp.Body.Close() + + sc := bufio.NewScanner(resp.Body) + sc.Buffer(nil, 1<<24) + + var ( + e ServerSentEvent[T] + data []string + ) + + // emit yields the event read so far, if it has data, and starts the next. + emit := func() bool { + defer func() { e, data = ServerSentEvent[T]{ID: e.ID}, nil }() + + if data == nil { + return true + } + + err := eventData(strings.Join(data, "\n"), &e.Data) + + return yield(e, err) && err == nil + } + + for first := true; sc.Scan(); first = false { + line := sc.Text() + if first { + line = strings.TrimPrefix(line, "\ufeff") + } + + field, value, _ := strings.Cut(line, ":") + value = strings.TrimPrefix(value, " ") + + switch field { + case "": + if line == "" && !emit() { + return + } + case "data": + data = append(data, value) + case "event": + e.Event = value + case "id": + e.ID = value + case "retry": + e.Retry, _ = strconv.Atoi(value) + } + } + + if err := sc.Err(); err != nil { + yield(ServerSentEvent[T]{}, err) + } + }, nil +} + +// eventData reads an event's data into out: the text itself for a string, +// else JSON. +func eventData[T any](data string, out *T) error { + if p, ok := any(out).(*string); ok { + *p = data + + return nil + } + + return json.Unmarshal([]byte(data), out) +} + +// discard drains and closes resp's body. +func discard(resp *http.Response, err error) error { + if err != nil { + return err + } + + defer resp.Body.Close() + + _, err = io.Copy(io.Discard, resp.Body) + + return err +} + +// value dereferences v; ok is false for nil, a nil pointer and an empty +// slice or map, which a parameter leaves out. +func value(v any) (any, bool) { + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Pointer || rv.Kind() == reflect.Interface { + if rv.IsNil() { + return nil, false + } + + rv = rv.Elem() + } + + switch rv.Kind() { + case reflect.Invalid: + return nil, false + case reflect.Slice, reflect.Map: + if rv.Len() == 0 { + return nil, false + } + } + + return rv.Interface(), true +} + +// paramString formats a scalar for a parameter: RFC 3339 for a time (as +// DateTime has it), 2006-01-02 for a Date, base64 for bytes, the shortest +// decimal for a float. +func paramString(v any) string { + switch v := v.(type) { + case time.Time: + return v.Format(time.RFC3339Nano) + case DateTime: + return v.String() + case Date: + return v.String() + case json.RawMessage: + var text string + if json.Unmarshal(v, &text) == nil { + return text + } + + return string(v) + + case []byte: + return base64.StdEncoding.EncodeToString(v) + case string: + return v + } + + switch rv := reflect.ValueOf(v); rv.Kind() { + case reflect.Float32: + return strconv.FormatFloat(rv.Float(), 'f', -1, 32) + case reflect.Float64: + return strconv.FormatFloat(rv.Float(), 'f', -1, 64) + } + + return fmt.Sprint(v) +} + +// isScalar reports whether v is a parameter value of its own, not a list or +// an object. +func isScalar(v any) bool { + switch v.(type) { + case time.Time, DateTime, Date, []byte, json.RawMessage: + return true + } + + switch reflect.ValueOf(v).Kind() { + case reflect.Slice, reflect.Array, reflect.Map, reflect.Struct: + return false + } + + return true +} + +// paramList formats each element of a slice, a nil one as "". +func paramList(v any) []string { + rv := reflect.ValueOf(v) + out := make([]string, rv.Len()) + + for i := range out { + if e, ok := value(rv.Index(i).Interface()); ok { + out[i] = paramString(e) + } + } + + return out +} + +// paramObject flattens a struct or map to its keys, sorted, and their values, +// through JSON so the keys are the wire names; a nested value stays JSON. +func paramObject(v any) ([]string, []string, error) { + tree, err := jsonTree(v) + if err != nil { + return nil, nil, err + } + + m, ok := tree.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("parameter value %T is not an object", v) + } + + keys := slices.Sorted(maps.Keys(m)) + + values := make([]string, len(keys)) + for i, k := range keys { + values[i] = treeString(m[k]) + } + + return keys, values, nil +} + +// jsonTree is v through JSON: maps, slices and scalars, numbers as written. +func jsonTree(v any) (any, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + + d := json.NewDecoder(bytes.NewReader(b)) + d.UseNumber() + + var tree any + err = d.Decode(&tree) + + return tree, err +} + +// treeString formats a jsonTree value: a scalar as text, an object or list +// as JSON. +func treeString(v any) string { + switch v.(type) { + case map[string]any, []any: + b, _ := json.Marshal(v) // it came from JSON + + return string(b) + } + + return fmt.Sprint(v) +} + +// addQuery adds the parameter name holding v to q, in the given OpenAPI +// style (form, spaceDelimited, pipeDelimited, deepObject). +func addQuery(q url.Values, name string, v any, style string, explode bool) error { + v, ok := value(v) + if !ok { + return nil + } + + if isScalar(v) { + q.Add(name, paramString(v)) + + return nil + } + + if style == "deepObject" { + return addDeep(q, name, v) + } + + if k := reflect.ValueOf(v).Kind(); k == reflect.Slice || k == reflect.Array { + items := paramList(v) + + switch { + case explode: + for _, s := range items { + q.Add(name, s) + } + case style == "spaceDelimited": + q.Add(name, strings.Join(items, " ")) + case style == "pipeDelimited": + q.Add(name, strings.Join(items, "|")) + default: + q.Add(name, strings.Join(items, ",")) + } + + return nil + } + + keys, values, err := paramObject(v) + if err != nil { + return err + } + + if explode { + for i, k := range keys { + q.Add(k, values[i]) + } + } else { + q.Add(name, joinPairs(keys, values, ",", ",")) + } + + return nil +} + +// addDeep adds v to q in deepObject style, nested objects and lists spelled +// out a key at a time through JSON: name[a][b]=x, name[list][0]=y. +func addDeep(q url.Values, name string, v any) error { + tree, err := jsonTree(v) + if err != nil { + return err + } + + addTree(q, name, tree) + + return nil +} + +func addTree(q url.Values, name string, v any) { + switch v := v.(type) { + case map[string]any: + for _, k := range slices.Sorted(maps.Keys(v)) { + addTree(q, name+"["+k+"]", v[k]) + } + + case []any: + for i, e := range v { + addTree(q, name+"["+strconv.Itoa(i)+"]", e) + } + + case nil: // a null leaves the key out + default: + q.Add(name, treeString(v)) + } +} + +func joinPairs(keys, values []string, kv, sep string) string { + pairs := make([]string, len(keys)) + for i, k := range keys { + pairs[i] = k + kv + values[i] + } + + return strings.Join(pairs, sep) +} + +// expandPath replaces placeholder in *path with v, formatted for the path +// parameter name in the given OpenAPI style (simple, label, matrix). +func expandPath(path *string, placeholder string, v any, name, style string, explode bool) error { + s, err := pathParam(v, name, style, explode) + *path = strings.ReplaceAll(*path, placeholder, s) + + return err +} + +// pathParam formats v for a path parameter, each value escaped on its own. +func pathParam(v any, name, style string, explode bool) (string, error) { + v, ok := value(v) + if !ok { + return "", nil + } + + var keys, values []string + + switch k := reflect.ValueOf(v).Kind(); { + case isScalar(v): + values = []string{paramString(v)} + case k == reflect.Slice || k == reflect.Array: + values = paramList(v) + default: + var err error + if keys, values, err = paramObject(v); err != nil { + return "", err + } + } + + for i := range values { + values[i] = url.PathEscape(values[i]) + } + + for i := range keys { + keys[i] = url.PathEscape(keys[i]) + } + + name = url.PathEscape(name) + + sep, prefix := ",", "" + + switch style { + case "label": + prefix = "." + if explode { + sep = "." + } + case "matrix": + prefix = ";" + name + "=" + if explode { + sep = prefix + if keys != nil { + prefix, sep = ";", ";" + } + } + } + + if keys == nil { + return prefix + strings.Join(values, sep), nil + } + + if explode { + return prefix + joinPairs(keys, values, "=", sep), nil + } + + return prefix + joinPairs(keys, values, ",", ","), nil +} + +// headerParam formats v for a header or cookie parameter (simple style); ok +// is false when there is nothing to send. +func headerParam(v any, explode bool) (string, bool, error) { + v, ok := value(v) + if !ok { + return "", false, nil + } + + if isScalar(v) { + return paramString(v), true, nil + } + + if k := reflect.ValueOf(v).Kind(); k == reflect.Slice || k == reflect.Array { + return strings.Join(paramList(v), ","), true, nil + } + + keys, values, err := paramObject(v) + if err != nil { + return "", false, err + } + + if explode { + return joinPairs(keys, values, "=", ","), true, nil + } + + return joinPairs(keys, values, ",", ","), true, nil +} + +func setHeader(req *http.Request, name string, v any, explode bool) error { + s, ok, err := headerParam(v, explode) + if ok { + req.Header.Set(name, s) + } + + return err +} + +func addCookie(req *http.Request, name string, v any, explode bool) error { + s, ok, err := headerParam(v, explode) + if ok { + req.AddCookie(&http.Cookie{Name: name, Value: s}) + } + + return err +} + +// jsonBody encodes v as a JSON body; nil (pointer, slice, map) sends none, +// or null for a required nullable body. +func jsonBody(v any, null bool) (io.Reader, error) { + rv := reflect.ValueOf(v) + + switch k := rv.Kind(); { + case k == reflect.Invalid, (k == reflect.Pointer || k == reflect.Slice || k == reflect.Map) && rv.IsNil(): + if null { + return strings.NewReader("null"), nil + } + + return nil, nil + } + + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + + return bytes.NewReader(b), nil +} + +// textBody sends a string as it is. +func textBody(v any) io.Reader { + v, ok := value(v) + if !ok { + return nil + } + + return strings.NewReader(paramString(v)) +} + +// addPart writes the form field name holding v: a File as a file part, a +// list of files as one part each, a list of scalars one part each (one +// comma-separated part unless explode), an object as a JSON part. +func addPart(w *multipart.Writer, name string, v any, explode bool) error { + v, ok := value(v) + if !ok { + return nil + } + + switch v := v.(type) { + case File: + return addFile(w, name, v) + case []File: + for _, f := range v { + if err := addFile(w, name, f); err != nil { + return err + } + } + + return nil + } + + if isScalar(v) { + return w.WriteField(name, paramString(v)) + } + + rv := reflect.ValueOf(v) + if k := rv.Kind(); (k == reflect.Slice || k == reflect.Array) && isScalar(reflect.Zero(rv.Type().Elem()).Interface()) { + if !explode { + return w.WriteField(name, strings.Join(paramList(v), ",")) + } + + for _, s := range paramList(v) { + if err := w.WriteField(name, s); err != nil { + return err + } + } + + return nil + } + + b, err := json.Marshal(v) + if err != nil { + return err + } + + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", `form-data; name="`+strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(name)+`"`) + h.Set("Content-Type", "application/json") + + part, err := w.CreatePart(h) + if err != nil { + return err + } + + _, err = part.Write(b) + + return err +} + +func addFile(w *multipart.Writer, name string, f File) error { + filename := f.Name + if filename == "" { + filename = name + } + + part, err := w.CreateFormFile(name, filename) + if err != nil || f.Body == nil { + return err + } + + _, err = io.Copy(part, f.Body) + + return err +} diff --git a/templates/go-fetch/union.mustache b/templates/go-fetch/union.mustache new file mode 100644 index 0000000..17c6781 --- /dev/null +++ b/templates/go-fetch/union.mustache @@ -0,0 +1,89 @@ +{{#vendorExtensions.x-go-doc}} +{{{.}}} +{{/vendorExtensions.x-go-doc}} +type {{classname}} struct { + union json.RawMessage +} + +// MarshalJSON returns the value as it is, null when unset. +func (u {{classname}}) MarshalJSON() ([]byte, error) { + if u.union == nil { + return []byte("null"), nil + } + + return u.union, nil +} + +// UnmarshalJSON keeps a copy of b. +func (u *{{classname}}) UnmarshalJSON(b []byte) error { + u.union = append(json.RawMessage(nil), b...) + + return nil +} +{{#vendorExtensions.x-go-variants}} + +// As{{method}} decodes the value as a {{{type}}}{{#required}}, an error when it lacks +// a property the {{{type}}} requires{{/required}}{{#pinned}}, or its {{key}} is not {{{literal}}}{{/pinned}}. +func (u {{classname}}) As{{method}}() ({{{type}}}, error) { + var v {{{type}}} +{{#required}} + if err := requireKeys(u.union, {{{.}}}); err != nil { + return v, err + } + +{{/required}} + err := json.Unmarshal(u.union, &v) +{{#pinned}} + if err == nil && v.{{field}} != {{{literal}}} { + err = fmt.Errorf({{{mismatch}}}, v.{{field}}) + } +{{/pinned}} + + return v, err +} + +// From{{method}} sets the value to v{{#pinned}}, its {{key}} set to {{{literal}}}{{/pinned}}. +func (u *{{classname}}) From{{method}}(v {{{type}}}) error { +{{#pinned}} + v.{{field}} = {{{literal}}} +{{/pinned}} + b, err := json.Marshal(v) + if err == nil { + u.union = b + } + + return err +} +{{/vendorExtensions.x-go-variants}} +{{#vendorExtensions.x-go-discriminator}} + +// Discriminator returns the value's {{name}} property. +func (u {{classname}}) Discriminator() (string, error) { + var d struct { + Value string {{{tag}}} + } + err := json.Unmarshal(u.union, &d) + + return d.Value, err +} + +// ValueByDiscriminator decodes the value into a pointer to the model its +// {{name}} property names. +func (u {{classname}}) ValueByDiscriminator() (any, error) { + d, err := u.Discriminator() + if err != nil { + return nil, err + } + + switch d { +{{#mappings}} + case {{{value}}}: + v := new({{type}}) + + return v, json.Unmarshal(u.union, v) +{{/mappings}} + } + + return nil, errors.New("{{classname}}: unknown {{name}} " + strconv.Quote(d)) +} +{{/vendorExtensions.x-go-discriminator}} diff --git a/templates/typescript-fetch/apis.mustache b/templates/typescript-fetch/apis.mustache index 7fd6557..fc8edd3 100644 --- a/templates/typescript-fetch/apis.mustache +++ b/templates/typescript-fetch/apis.mustache @@ -184,29 +184,29 @@ export class {{classname}} extends runtime.BaseAPI { {{/jsonHeaderUsesModelSerializer}} {{/isArray}}{{/headerParams}}{{#authMethods}}{{#isBasic}}{{#isBasicBasic}} if (this.configuration && (this.configuration.username !== undefined || this.configuration.password !== undefined)) { - headerParameters["Authorization"] = "Basic " + btoa(this.configuration.username + ":" + this.configuration.password); + headerParameters["Authorization"] = "Basic " + btoa(this.configuration.username + ":" + this.configuration.password); } {{/isBasicBasic}}{{#isBasicBearer}} if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("{{name}}", [{{#scopes}}"{{{scope}}}"{{^-last}}, {{/-last}}{{/scopes}}]); + const token = this.configuration.accessToken; + const tokenString = await token("{{name}}", [{{#scopes}}"{{{scope}}}"{{^-last}}, {{/-last}}{{/scopes}}]); - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } } {{/isBasicBearer}}{{/isBasic}}{{#isApiKey}}{{#isKeyInHeader}} if (this.configuration && this.configuration.apiKey) { - headerParameters["{{keyParamName}}"] = await this.configuration.apiKey("{{keyParamName}}"); // {{name}} authentication + headerParameters["{{keyParamName}}"] = await this.configuration.apiKey("{{keyParamName}}"); // {{name}} authentication } {{/isKeyInHeader}}{{#isKeyInQuery}} if (this.configuration && this.configuration.apiKey) { - queryParameters["{{keyParamName}}"] = await this.configuration.apiKey("{{keyParamName}}"); // {{name}} authentication + queryParameters["{{keyParamName}}"] = await this.configuration.apiKey("{{keyParamName}}"); // {{name}} authentication } {{/isKeyInQuery}}{{/isApiKey}}{{#isOAuth}} if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken("{{name}}", [{{#scopes}}"{{{scope}}}"{{^-last}}, {{/-last}}{{/scopes}}]); + // oauth required + headerParameters["Authorization"] = await this.configuration.accessToken("{{name}}", [{{#scopes}}"{{{scope}}}"{{^-last}}, {{/-last}}{{/scopes}}]); } {{/isOAuth}}{{/authMethods}}{{#hasFormParams}} const consumes: runtime.Consume[] = [ diff --git a/testdata/gofetch/additional-properties-with-properties/extra_test.go b/testdata/gofetch/additional-properties-with-properties/extra_test.go new file mode 100644 index 0000000..1090ded --- /dev/null +++ b/testdata/gofetch/additional-properties-with-properties/extra_test.go @@ -0,0 +1,46 @@ +package additionalpropertieswithproperties + +import ( + "encoding/json" + "reflect" + "testing" +) + +// TestExtraKeys reads undeclared keys into AdditionalProperties and writes +// them back after the declared fields, typed by the schema. +func TestExtraKeys(t *testing.T) { + const in = `{"kind":"click","x":1,"tags":["a"]}` + + var e Event + if err := json.Unmarshal([]byte(in), &e); err != nil { + t.Fatal(err) + } + + if want := map[string]any{"x": 1.0, "tags": []any{"a"}}; e.Kind != "click" || !reflect.DeepEqual(e.AdditionalProperties, want) { + t.Errorf("decoded %#v", e) + } + + out, err := json.Marshal(e) + if err != nil || string(out) != `{"kind":"click","tags":["a"],"x":1}` { + t.Errorf("encoded %s, %v", out, err) + } + + // A declared key in the map does not shadow the field. + s := Scores{Player: "p", AdditionalProperties: map[string]int{"player": 9, "round1": 3}} + if out, _ := json.Marshal(s); string(out) != `{"player":"p","round1":3}` { + t.Errorf("encoded %s", out) + } + + if err := json.Unmarshal([]byte(`{"player":"p","bad":"x"}`), &s); err == nil { + t.Error("a string decoded as an int score") + } + + if out, _ := json.Marshal(Event{Kind: "k"}); string(out) != `{"kind":"k"}` { + t.Errorf("no extras: %s", out) + } + + // Extras only, the declared fields all left out. + if out, _ := json.Marshal(Notes{AdditionalProperties: map[string]string{"b": "2", "a": "1"}}); string(out) != `{"a":"1","b":"2"}` { + t.Errorf("extras only: %s", out) + } +} diff --git a/testdata/gofetch/composition-anyof/union_test.go b/testdata/gofetch/composition-anyof/union_test.go new file mode 100644 index 0000000..07cc68e --- /dev/null +++ b/testdata/gofetch/composition-anyof/union_test.go @@ -0,0 +1,31 @@ +package compositionanyof + +import ( + "encoding/json" + "testing" +) + +func TestAnyOf(t *testing.T) { + var c ContactMethod + if err := json.Unmarshal([]byte(`{"email":"a@b.c","phone":"1"}`), &c); err != nil { + t.Fatal(err) + } + + email, err := c.AsEmailContact() + if err != nil || email.Email != "a@b.c" { + t.Errorf("AsEmailContact = %+v, %v", email, err) + } + + phone, err := c.AsPhoneContact() + if err != nil || phone.Phone != "1" { + t.Errorf("AsPhoneContact = %+v, %v", phone, err) + } + + if err := c.FromPhoneContact(PhoneContact{Phone: "2"}); err != nil { + t.Fatal(err) + } + + if b, _ := json.Marshal(c); string(b) != `{"phone":"2"}` { + t.Errorf("Marshal = %s", b) + } +} diff --git a/testdata/gofetch/composition-oneof-inline/shelter_test.go b/testdata/gofetch/composition-oneof-inline/shelter_test.go new file mode 100644 index 0000000..2db2522 --- /dev/null +++ b/testdata/gofetch/composition-oneof-inline/shelter_test.go @@ -0,0 +1,94 @@ +package compositiononeofinline + +import ( + "encoding/json" + "io" + "testing" +) + +// TestInlineUnion decodes inline unions into their variants and encodes +// them back unchanged. +func TestInlineUnion(t *testing.T) { + const in = `{"favourite":{"kind":"cat","lives":3},"residents":[{"kind":"dog","barkVolume":2}]}` + + var s Shelter + if err := json.Unmarshal([]byte(in), &s); err != nil { + t.Fatal(err) + } + + if cat, err := s.Favourite.AsCat(); err != nil || cat.Lives != 3 { + t.Errorf("favourite AsCat = %+v, %v", cat, err) + } + + if _, err := s.Favourite.AsDog(); err == nil { + t.Error("favourite AsDog of a cat succeeded") + } + + if dog, err := s.Residents[0].AsDog(); err != nil || dog.BarkVolume != 2 { + t.Errorf("resident AsDog = %+v, %v", dog, err) + } + + out, err := json.Marshal(s) + if err != nil || string(out) != in { + t.Errorf("encoded %s, %v\nwant %s", out, err, in) + } +} + +// TestInlineUnionBody sends the variant set with From as the body. +func TestInlineUnionBody(t *testing.T) { + var body AdmitBody + if err := body.FromDog(Dog{Kind: "dog", BarkVolume: 1.5}); err != nil { + t.Fatal(err) + } + + req, err := NewDefaultAPI(&Configuration{BasePath: "http://x"}).AdmitRequest(t.Context(), AdmitParams{ID: "s1", Body: body}) + if err != nil { + t.Fatal(err) + } + + got, _ := io.ReadAll(req.Body) + if want := `{"kind":"dog","barkVolume":1.5}`; string(got) != want { + t.Errorf("body %s, want %s", got, want) + } +} + +// TestImpliedDiscriminator fills in and checks the kind each variant pins, +// and dispatches on it. +func TestImpliedDiscriminator(t *testing.T) { + var f ShelterFavourite + if err := f.FromDog(Dog{BarkVolume: 3}); err != nil { + t.Fatal(err) + } + + if b, _ := json.Marshal(f); string(b) != `{"kind":"dog","barkVolume":3}` { + t.Errorf("FromDog = %s", b) + } + + // Every key a Cat requires is there, but the kind says dog. + _ = json.Unmarshal([]byte(`{"kind":"dog","lives":1,"barkVolume":3}`), &f) + if _, err := f.AsCat(); err == nil { + t.Error("AsCat of a dog succeeded") + } + + v, err := f.ValueByDiscriminator() + if dog, ok := v.(*Dog); err != nil || !ok || dog.BarkVolume != 3 { + t.Errorf("ValueByDiscriminator = %#v, %v", v, err) + } +} + +// TestInlineObjectVariants gives each inline object member a struct, told +// apart by its required properties. +func TestInlineObjectVariants(t *testing.T) { + var s Shelter + if err := json.Unmarshal([]byte(`{"favourite":{"kind":"cat","lives":1},"location":{"lat":1.5,"lon":2}}`), &s); err != nil { + t.Fatal(err) + } + + if _, err := s.Location.AsShelterLocationOneOf(); err == nil { + t.Error("a point read as a street") + } + + if p, err := s.Location.AsShelterLocationOneOf1(); err != nil || p.Lat != 1.5 || p.Lon != 2 { + t.Errorf("AsShelterLocationOneOf1 = %+v, %v", p, err) + } +} diff --git a/testdata/gofetch/composition-oneof/union_test.go b/testdata/gofetch/composition-oneof/union_test.go new file mode 100644 index 0000000..6074f0e --- /dev/null +++ b/testdata/gofetch/composition-oneof/union_test.go @@ -0,0 +1,52 @@ +package compositiononeof + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +func TestOneOfRoundTrip(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) // echo + })) + defer srv.Close() + + var in SearchResult + if err := in.FromCat(Cat{PetType: "cat", HuntingSkill: "lazy"}); err != nil { + t.Fatal(err) + } + + out, err := NewDefaultAPI(&Configuration{BasePath: srv.URL}).Search(context.Background(), SearchParams{SearchResult: in}) + if err != nil { + t.Fatal(err) + } + + if cat, err := out.AsCat(); err != nil || cat.HuntingSkill != "lazy" { + t.Errorf("AsCat = %+v, %v", cat, err) + } + + var list SearchResult + if err := json.Unmarshal([]byte(`[1,2]`), &list); err != nil { + t.Fatal(err) + } + + if ints, err := list.AsIntList(); err != nil || !reflect.DeepEqual(ints, []int{1, 2}) { + t.Errorf("AsIntList = %v, %v", ints, err) + } + + if _, err := list.AsString(); err == nil { + t.Error("AsString of a list succeeded") + } + + if b, err := json.Marshal(SearchResult{}); err != nil || string(b) != "null" { + t.Errorf("empty union = %s, %v", b, err) + } +} diff --git a/testdata/gofetch/dates/dates_test.go b/testdata/gofetch/dates/dates_test.go new file mode 100644 index 0000000..95108c6 --- /dev/null +++ b/testdata/gofetch/dates/dates_test.go @@ -0,0 +1,74 @@ +package dates + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +func TestDates(t *testing.T) { + var query url.Values + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"startDate":"2024-01-02","createdAt":"2024-01-02T03:04:05Z","reminderDates":["2024-01-01"]}]`)) + })) + defer srv.Close() + + at := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + api := NewDefaultAPI(&Configuration{BasePath: srv.URL}) + + events, err := api.ListEvents(context.Background(), ListEventsParams{Since: &DateTime{Time: at}, OnDay: Date{at}}) + if err != nil { + t.Fatal(err) + } + + if query.Get("since") != "2024-01-02T03:04:05Z" || query.Get("onDay") != "2024-01-02" { + t.Errorf("query = %v", query) + } + + e := events[0] + if e.StartDate.String() != "2024-01-02" || !e.CreatedAt.Equal(at) || e.EndDate.Set || e.ReminderDates[0].String() != "2024-01-01" { + t.Errorf("event = %+v", e) + } + + out, err := json.Marshal(Event{StartDate: e.StartDate, CreatedAt: DateTime{Time: at}, EndDate: Some(Date{at})}) + if err != nil { + t.Fatal(err) + } + + if want := `{"startDate":"2024-01-02","endDate":"2024-01-02","createdAt":"2024-01-02T03:04:05Z"}`; string(out) != want { + t.Errorf("Marshal = %s, want %s", out, want) + } +} + +// TestNaiveDateTime reads a date-time without an offset, as FastAPI sends a +// naive datetime, and writes it back without one. +func TestNaiveDateTime(t *testing.T) { + for in, want := range map[string]string{ + `"2024-03-01T10:00:00.500000"`: `"2024-03-01T10:00:00.500000"`, + `"2024-03-01T10:00:00"`: `"2024-03-01T10:00:00"`, + `"2024-03-01T10:00:00.5"`: `"2024-03-01T10:00:00.500000"`, + `"2024-03-01T10:00:00+02:00"`: `"2024-03-01T10:00:00+02:00"`, + } { + var d DateTime + if err := json.Unmarshal([]byte(in), &d); err != nil { + t.Fatalf("%s: %v", in, err) + } + + if out, _ := json.Marshal(d); string(out) != want { + t.Errorf("%s -> %s, want %s", in, out, want) + } + } + + var d DateTime + if err := json.Unmarshal([]byte(`"yesterday"`), &d); err == nil { + t.Error("yesterday decoded") + } +} diff --git a/testdata/gofetch/dates/null_test.go b/testdata/gofetch/dates/null_test.go new file mode 100644 index 0000000..51f4d5c --- /dev/null +++ b/testdata/gofetch/dates/null_test.go @@ -0,0 +1,21 @@ +package dates + +import ( + "encoding/json" + "testing" +) + +// TestDateNullAndInvalid reads null as the zero Date and refuses a date +// that is not one. +func TestDateNullAndInvalid(t *testing.T) { + d := Date{} + if err := json.Unmarshal([]byte("null"), &d); err != nil || !d.IsZero() { + t.Errorf("null: %v, %v", d, err) + } + + for _, in := range []string{`"2024-13-01"`, `20240101`} { + if err := json.Unmarshal([]byte(in), &d); err == nil { + t.Errorf("%s decoded as %v", in, d) + } + } +} diff --git a/testdata/gofetch/discriminator-oneof/union_test.go b/testdata/gofetch/discriminator-oneof/union_test.go new file mode 100644 index 0000000..90e825b --- /dev/null +++ b/testdata/gofetch/discriminator-oneof/union_test.go @@ -0,0 +1,40 @@ +package discriminatoroneof + +import ( + "encoding/json" + "fmt" + "testing" +) + +func TestValueByDiscriminator(t *testing.T) { + for _, tt := range []struct { + json, want string + }{ + {`{"petType":"cat","huntingSkill":"lazy"}`, "*discriminatoroneof.Cat"}, + {`{"petType":"dog","packSize":3}`, "*discriminatoroneof.Dog"}, + {`{"petType":"canine","packSize":3}`, "*discriminatoroneof.Dog"}, + } { + var p Pet + if err := json.Unmarshal([]byte(tt.json), &p); err != nil { + t.Fatal(err) + } + + v, err := p.ValueByDiscriminator() + if got := fmt.Sprintf("%T", v); err != nil || got != tt.want { + t.Errorf("%s: ValueByDiscriminator = %s, %v, want %s", tt.json, got, err, tt.want) + } + } + + var p Pet + if err := json.Unmarshal([]byte(`{"petType":"bird"}`), &p); err != nil { + t.Fatal(err) + } + + if d, err := p.Discriminator(); err != nil || d != "bird" { + t.Errorf("Discriminator = %q, %v", d, err) + } + + if v, err := p.ValueByDiscriminator(); err == nil { + t.Errorf("unknown petType decoded to %v", v) + } +} diff --git a/testdata/gofetch/maps-of-models/inventory_test.go b/testdata/gofetch/maps-of-models/inventory_test.go new file mode 100644 index 0000000..b919176 --- /dev/null +++ b/testdata/gofetch/maps-of-models/inventory_test.go @@ -0,0 +1,30 @@ +package mapsofmodels + +import ( + "encoding/json" + "testing" +) + +// TestEmptyContainerIsSent checks that an optional map or slice is left out +// only when nil: an empty one is a value, {} or []. +func TestEmptyContainerIsSent(t *testing.T) { + tests := []struct { + in Inventory + want string + }{ + {Inventory{}, `{}`}, + {Inventory{ByWarehouse: map[string]Stock{}}, `{"byWarehouse":{}}`}, + {Inventory{HistoryByDate: map[string][]Stock{"d": {}}}, `{"historyByDate":{"d":[]}}`}, + } + + for _, tt := range tests { + out, err := json.Marshal(tt.in) + if err != nil { + t.Fatal(err) + } + + if string(out) != tt.want { + t.Errorf("Marshal(%+v) = %s, want %s", tt.in, out, tt.want) + } + } +} diff --git a/testdata/gofetch/naming-error-model/error_test.go b/testdata/gofetch/naming-error-model/error_test.go new file mode 100644 index 0000000..dfcfb83 --- /dev/null +++ b/testdata/gofetch/naming-error-model/error_test.go @@ -0,0 +1,32 @@ +package namingerrormodel + +import ( + "bytes" + "errors" + "io" + "net/http" + "testing" +) + +type status500 string + +func (b status500) Do(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 500, Header: http.Header{}, Body: io.NopCloser(bytes.NewBufferString(string(b)))}, nil +} + +// TestErrorModelKeys decodes an error body into the declared model only +// when it holds the model's required properties. +func TestErrorModelKeys(t *testing.T) { + for body, want := range map[string]bool{`{"message":"boom"}`: true, `{"detail":"Not Found"}`: false, `not json`: false} { + _, err := NewDefaultAPI(&Configuration{HTTPClient: status500(body)}).DoRisky(t.Context()) + + var rerr *ResponseError + if !errors.As(err, &rerr) { + t.Fatalf("%s: error %v", body, err) + } + + if m, ok := rerr.Model.(*Error); ok != want || (ok && m.Message != "boom") { + t.Errorf("%s: model = %#v", body, rerr.Model) + } + } +} diff --git a/testdata/gofetch/nested-inline-schemas/profile_test.go b/testdata/gofetch/nested-inline-schemas/profile_test.go new file mode 100644 index 0000000..7762fb2 --- /dev/null +++ b/testdata/gofetch/nested-inline-schemas/profile_test.go @@ -0,0 +1,31 @@ +package nestedinlineschemas + +import ( + "encoding/json" + "testing" +) + +// TestInlineObjects checks that inline objects decode into their declared +// types, nested and as slice elements, and encode back unchanged. +func TestInlineObjects(t *testing.T) { + const in = `{"user":{"name":"ann","address":{"street":"Main","geo":{"lat":1.5,"lng":-2}}},"settings":[{"key":"k","value":{"raw":"r"}}]}` + + var p Profile + if err := json.Unmarshal([]byte(in), &p); err != nil { + t.Fatal(err) + } + + var geo *ProfileUserAddressGeo = p.User.Address.Geo + if *geo.Lat != 1.5 || *p.Settings[0].Value.Raw != "r" { + t.Errorf("decoded %+v", p) + } + + out, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + + if string(out) != in { + t.Errorf("round trip = %s", out) + } +} diff --git a/testdata/gofetch/nullable-openapi31/pet_test.go b/testdata/gofetch/nullable-openapi31/pet_test.go new file mode 100644 index 0000000..4a3b600 --- /dev/null +++ b/testdata/gofetch/nullable-openapi31/pet_test.go @@ -0,0 +1,86 @@ +package nullableopenapi31 + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" +) + +// TestNullMembersTyped decodes the 3.1 spellings of null into the member's +// type and encodes them back unchanged. +func TestNullMembersTyped(t *testing.T) { + const in = `{"kind":"pet","name":"Mia","size":null,"owner":{"name":"Ann"},"born":"2020-05-17","nickname":null,"tags":["a"]}` + + var p Pet + if err := json.Unmarshal([]byte(in), &p); err != nil { + t.Fatal(err) + } + + // A component that is a nullable string is one wherever it is referred to. + var alias Opt[string] = p.Alias + + if alias.Set || p.Kind != PetKindEnumPet || p.Size != nil || p.Owner.Value.Name != "Ann" || p.Born.Value.Day() != 17 || !p.Nickname.Null || p.Tags.Value[0] != "a" { + t.Errorf("decoded %+v", p) + } + + p.Size = Ptr(Size2) + + out, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + + if want := strings.Replace(in, `"size":null`, `"size":2`, 1); string(out) != want { + t.Errorf("encoded %s\nwant %s", out, want) + } +} + +type recorder struct{ req *http.Request } + +func (r *recorder) Do(req *http.Request) (*http.Response, error) { + r.req = req + + return &http.Response{StatusCode: 204, Body: http.NoBody, Header: http.Header{}}, nil +} + +// TestContentMediaTypeIsFile sends a contentMediaType string as a file part. +func TestContentMediaTypeIsFile(t *testing.T) { + rec := &recorder{} + api := NewDefaultAPI(&Configuration{BasePath: "http://x", HTTPClient: rec}) + + err := api.UploadPhoto(t.Context(), UploadPhotoParams{ + ID: "1", File: File{Name: "a.png", Body: strings.NewReader("png")}, + Tags: []string{"a", "b"}, Labels: []string{"x", "y"}, + }) + if err != nil { + t.Fatal(err) + } + + mr, err := rec.req.MultipartReader() + if err != nil { + t.Fatal(err) + } + + part, err := mr.NextPart() + if err != nil { + t.Fatal(err) + } + + body, _ := io.ReadAll(part) + if part.FormName() != "file" || part.FileName() != "a.png" || string(body) != "png" { + t.Errorf("part %s %q = %q", part.FormName(), part.FileName(), body) + } + + // An array is a part per item, one comma-joined part with explode: false. + form, err := mr.ReadForm(1 << 20) + if err != nil { + t.Fatal(err) + } + + if got := fmt.Sprint(form.Value); got != "map[labels:[x,y] tags:[a b]]" { + t.Errorf("fields = %s", got) + } +} diff --git a/testdata/gofetch/nullable-required/record_test.go b/testdata/gofetch/nullable-required/record_test.go new file mode 100644 index 0000000..90625f8 --- /dev/null +++ b/testdata/gofetch/nullable-required/record_test.go @@ -0,0 +1,50 @@ +package nullablerequired + +import ( + "encoding/json" + "testing" +) + +func TestOptRoundTrip(t *testing.T) { + const head = `{"requiredNotNullable":"a","requiredNullable":null` + + tests := []struct { + json string + want Opt[string] + }{ + {head + `}`, Opt[string]{}}, + {head + `,"optionalNullable":null}`, Null[string]()}, + {head + `,"optionalNullable":"x"}`, Some("x")}, + } + + for _, tt := range tests { + var r Record + if err := json.Unmarshal([]byte(tt.json), &r); err != nil { + t.Fatal(err) + } + + if r.OptionalNullable != tt.want { + t.Errorf("%s: optionalNullable = %+v, want %+v", tt.json, r.OptionalNullable, tt.want) + } + + out, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + + if string(out) != tt.json { + t.Errorf("round trip of %s = %s", tt.json, out) + } + } +} + +func TestRequiredNullableIsSent(t *testing.T) { + out, err := json.Marshal(Record{RequiredNotNullable: "a", RequiredNullable: Ptr("b")}) + if err != nil { + t.Fatal(err) + } + + if want := `{"requiredNotNullable":"a","requiredNullable":"b"}`; string(out) != want { + t.Errorf("Marshal = %s, want %s", out, want) + } +} diff --git a/testdata/gofetch/parameters-array-style-explode/params_test.go b/testdata/gofetch/parameters-array-style-explode/params_test.go new file mode 100644 index 0000000..7c01546 --- /dev/null +++ b/testdata/gofetch/parameters-array-style-explode/params_test.go @@ -0,0 +1,47 @@ +package parametersarraystyleexplode + +import ( + "context" + "reflect" + "testing" +) + +func TestArrayStyles(t *testing.T) { + req, err := NewDefaultAPI(nil).FilterItemsRequest(context.Background(), FilterItemsParams{ + FormExplodeTrue: []string{"a", "b"}, + FormExplodeFalse: []string{"a", "b"}, + SpaceDelimited: []int{1, 2}, + PipeDelimited: []int{1, 2}, + PathIds: []string{"x y", "z/w"}, + }) + if err != nil { + t.Fatal(err) + } + + q := req.URL.Query() + for name, want := range map[string][]string{ + "formExplodeTrue": {"a", "b"}, + "formExplodeFalse": {"a,b"}, + "spaceDelimited": {"1 2"}, + "pipeDelimited": {"1|2"}, + } { + if !reflect.DeepEqual(q[name], want) { + t.Errorf("%s = %q, want %q", name, q[name], want) + } + } + + if got, want := req.URL.EscapedPath(), "/filter/x%20y,z%2Fw"; got != want { + t.Errorf("path = %s, want %s", got, want) + } +} + +func TestEmptyArrayIsLeftOut(t *testing.T) { + req, err := NewDefaultAPI(nil).FilterItemsRequest(context.Background(), FilterItemsParams{PathIds: []string{"x"}}) + if err != nil { + t.Fatal(err) + } + + if req.URL.RawQuery != "" { + t.Errorf("query = %q, want none", req.URL.RawQuery) + } +} diff --git a/testdata/gofetch/parameters-deepobject/params_test.go b/testdata/gofetch/parameters-deepobject/params_test.go new file mode 100644 index 0000000..52e3d90 --- /dev/null +++ b/testdata/gofetch/parameters-deepobject/params_test.go @@ -0,0 +1,20 @@ +package parametersdeepobject + +import ( + "context" + "testing" +) + +func TestDeepObject(t *testing.T) { + req, err := NewDefaultAPI(nil).DeepSearchRequest(context.Background(), DeepSearchParams{ + Filter: &DeepSearchFilter{Name: Ptr("cat food"), MinPrice: Ptr(1.5)}, + }) + if err != nil { + t.Fatal(err) + } + + q := req.URL.Query() + if q.Get("filter[name]") != "cat food" || q.Get("filter[minPrice]") != "1.5" || len(q) != 2 { + t.Errorf("query = %v", q) + } +} diff --git a/testdata/gofetch/parameters-explode-object/params_test.go b/testdata/gofetch/parameters-explode-object/params_test.go new file mode 100644 index 0000000..ac265cf --- /dev/null +++ b/testdata/gofetch/parameters-explode-object/params_test.go @@ -0,0 +1,34 @@ +package parametersexplodeobject + +import ( + "context" + "testing" +) + +func TestExplodedObjects(t *testing.T) { + req, err := NewDefaultAPI(nil).ListItemsRequest(context.Background(), ListItemsParams{ + Labels: map[string]string{"color": "red", "size": "L"}, + Extra: map[string]any{"debug": true}, + Range: map[string]int{"min": 1, "max": 5}, + }) + if err != nil { + t.Fatal(err) + } + + q := req.URL.Query() + for name, want := range map[string]string{ + "color": "red", + "size": "L", + "debug": "true", + "range[min]": "1", + "range[max]": "5", + } { + if got := q.Get(name); got != want { + t.Errorf("%s = %q, want %q", name, got, want) + } + } + + if len(q) != 5 { + t.Errorf("query = %v, want 5 parameters", q) + } +} diff --git a/testdata/gofetch/parameters-locations/params_test.go b/testdata/gofetch/parameters-locations/params_test.go new file mode 100644 index 0000000..921f384 --- /dev/null +++ b/testdata/gofetch/parameters-locations/params_test.go @@ -0,0 +1,47 @@ +package parameterslocations + +import ( + "context" + "testing" +) + +func TestLocations(t *testing.T) { + api := NewDefaultAPI(nil) + + req, err := api.GetItemRequest(context.Background(), GetItemParams{ + OrgID: "acme corp", + ItemID: 7, + Verbose: Ptr(true), + XRequestID: Ptr("req-1"), + SessionID: Ptr("s1"), + }) + if err != nil { + t.Fatal(err) + } + + if got := req.URL.EscapedPath(); got != "/orgs/acme%20corp/items/7" { + t.Errorf("path = %s", got) + } + + if got := req.URL.Query().Get("verbose"); got != "true" { + t.Errorf("verbose = %q", got) + } + + if got := req.Header.Get("X-Request-Id"); got != "req-1" { + t.Errorf("X-Request-Id = %q", got) + } + + if c, err := req.Cookie("sessionId"); err != nil || c.Value != "s1" { + t.Errorf("sessionId cookie = %v, %v", c, err) + } + + // Optional parameters left nil are not sent. + req, err = api.GetItemRequest(context.Background(), GetItemParams{OrgID: "o", ItemID: 1}) + if err != nil { + t.Fatal(err) + } + + if req.URL.RawQuery != "" || req.Header.Get("X-Request-Id") != "" || len(req.Cookies()) != 0 { + t.Errorf("unset parameters sent: query %q, header %v", req.URL.RawQuery, req.Header) + } +} diff --git a/testdata/gofetch/parameters-locations/raw_test.go b/testdata/gofetch/parameters-locations/raw_test.go new file mode 100644 index 0000000..efc900f --- /dev/null +++ b/testdata/gofetch/parameters-locations/raw_test.go @@ -0,0 +1,24 @@ +package parameterslocations + +import ( + "encoding/json" + "net/url" + "testing" +) + +// TestRawMessageParam checks that raw JSON, the type of an inline union, +// goes out as one value: a JSON string unquoted, anything else as written. +func TestRawMessageParam(t *testing.T) { + q := url.Values{} + if err := addQuery(q, "s", json.RawMessage(`"abc"`), "form", true); err != nil { + t.Fatal(err) + } + + if err := addQuery(q, "n", json.RawMessage(`5`), "form", true); err != nil { + t.Fatal(err) + } + + if got := q.Encode(); got != "n=5&s=abc" { + t.Errorf("query = %s, want n=5&s=abc", got) + } +} diff --git a/testdata/gofetch/parameters-object-styles/params_test.go b/testdata/gofetch/parameters-object-styles/params_test.go new file mode 100644 index 0000000..f7153d1 --- /dev/null +++ b/testdata/gofetch/parameters-object-styles/params_test.go @@ -0,0 +1,38 @@ +package parametersobjectstyles + +import "testing" + +// TestObjectStyles checks each style against the OpenAPI serialization +// table, for color = {R: 100, G: 200, B: 150} and the array [blue, black]. +func TestObjectStyles(t *testing.T) { + color := Color{R: 100, G: 200, B: 150} + + req, err := NewDefaultAPI(nil).PaintRequest(t.Context(), PaintParams{ + A: color, B: color, C: color, D: color, E: color, F: color, G: []string{"blue", "black"}, + Q: &color, XColor: &color, XColorExploded: &color, XTags: []string{"x", "y"}, Prefs: []string{"p", "q"}, + }) + if err != nil { + t.Fatal(err) + } + + want := "/simple/B,150,G,200,R,100/B=150,G=200,R=100" + + "/label/.B,150,G,200,R,100/.B=150.G=200.R=100" + + "/matrix/;e=B,150,G,200,R,100/;B=150;G=200;R=100/;g=blue;g=black" + if got := req.URL.EscapedPath(); got != want { + t.Errorf("path = %s\nwant %s", got, want) + } + + if got := req.URL.RawQuery; got != "q=B%2C150%2CG%2C200%2CR%2C100" { + t.Errorf("query = %s", got) + } + + // net/http quotes a cookie value with a comma, as RFC 6265 has no comma + // in a bare cookie value. + for name, want := range map[string]string{ + "X-Color": "B,150,G,200,R,100", "X-Color-Exploded": "B=150,G=200,R=100", "X-Tags": "x,y", "Cookie": `prefs="p,q"`, + } { + if got := req.Header.Get(name); got != want { + t.Errorf("%s = %q, want %q", name, got, want) + } + } +} diff --git a/testdata/gofetch/parameters-path-style/params_test.go b/testdata/gofetch/parameters-path-style/params_test.go new file mode 100644 index 0000000..0df9bd7 --- /dev/null +++ b/testdata/gofetch/parameters-path-style/params_test.go @@ -0,0 +1,28 @@ +package parameterspathstyle + +import ( + "context" + "net/http" + "testing" +) + +func TestPathStyles(t *testing.T) { + api := NewDefaultAPI(nil) + ctx := context.Background() + ids := []string{"a", "b"} + + for want, build := range map[string]func() (*http.Request, error){ + "/simple/a,b": func() (*http.Request, error) { return api.GetSimplePathRequest(ctx, GetSimplePathParams{ID: ids}) }, + "/matrix/;id=a,b": func() (*http.Request, error) { return api.GetMatrixPathRequest(ctx, GetMatrixPathParams{ID: ids}) }, + "/label/.a.b": func() (*http.Request, error) { return api.GetLabelPathRequest(ctx, GetLabelPathParams{ID: ids}) }, + } { + req, err := build() + if err != nil { + t.Fatal(err) + } + + if got := req.URL.EscapedPath(); got != want { + t.Errorf("path = %s, want %s", got, want) + } + } +} diff --git a/testdata/gofetch/parameters-scalar-formats/params_test.go b/testdata/gofetch/parameters-scalar-formats/params_test.go new file mode 100644 index 0000000..56cf7cd --- /dev/null +++ b/testdata/gofetch/parameters-scalar-formats/params_test.go @@ -0,0 +1,26 @@ +package parametersscalarformats + +import ( + "testing" + "time" +) + +// TestScalarText sends each scalar as its shortest exact text: no float32 +// noise, no exponent, int64 above 2^53, RFC 3339 times, base64 text escaped. +func TestScalarText(t *testing.T) { + at := time.Date(2024, 2, 29, 23, 59, 58, 500, time.FixedZone("", -90*60)) + + req, err := NewDefaultAPI(nil).SearchRequest(t.Context(), SearchParams{ + F32: Ptr(float32(0.1)), F64: Ptr(1e21), N: Ptr(0.30000000000000004), I64: Ptr(int64(1<<62 + 1)), Ok: Ptr(false), + Day: &Date{Time: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)}, At: &DateTime{Time: at}, Raw: Ptr("+/8="), + }) + if err != nil { + t.Fatal(err) + } + + want := "at=2024-02-29T23%3A59%3A58.0000005-01%3A30&day=2024-01-02&f32=0.1&f64=1000000000000000000000" + + "&i64=4611686018427387905&n=0.30000000000000004&ok=false&raw=%2B%2F8%3D" + if got := req.URL.RawQuery; got != want { + t.Errorf("query = %s\nwant %s", got, want) + } +} diff --git a/testdata/gofetch/request-body-form-encoding/encoding_test.go b/testdata/gofetch/request-body-form-encoding/encoding_test.go new file mode 100644 index 0000000..65fc98d --- /dev/null +++ b/testdata/gofetch/request-body-form-encoding/encoding_test.go @@ -0,0 +1,47 @@ +package requestbodyformencoding + +import ( + "context" + "io" + "net/url" + "reflect" + "testing" +) + +// TestFormEncoding checks each form field against its encoding: deepObject +// spells out nested objects and lists, an array explodes by default. +func TestFormEncoding(t *testing.T) { + req, err := NewDefaultAPI(nil).CreateCustomerRequest(context.Background(), CreateCustomerParams{ + Name: "ann", + Address: &CreateCustomerAddress{City: Ptr("Oslo"), Lines: []string{"a", "b"}}, + Metadata: map[string]string{"k": "v"}, + Expand: []string{"x", "y"}, + Tags: []string{"t", "u"}, + Ids: []int{1, 2}, + }) + if err != nil { + t.Fatal(err) + } + + body, _ := io.ReadAll(req.Body) + + got, err := url.ParseQuery(string(body)) + if err != nil { + t.Fatal(err) + } + + want := url.Values{ + "name": {"ann"}, + "address[city]": {"Oslo"}, + "address[lines][0]": {"a"}, + "address[lines][1]": {"b"}, + "metadata[k]": {"v"}, + "expand[0]": {"x"}, + "expand[1]": {"y"}, + "tags": {"t,u"}, + "ids": {"1", "2"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("form = %v\nwant %v", got, want) + } +} diff --git a/testdata/gofetch/request-body-form-urlencoded/body_test.go b/testdata/gofetch/request-body-form-urlencoded/body_test.go new file mode 100644 index 0000000..a1f74f1 --- /dev/null +++ b/testdata/gofetch/request-body-form-urlencoded/body_test.go @@ -0,0 +1,75 @@ +package requestbodyformurlencoded + +import ( + "context" + "io" + "mime" + "mime/multipart" + "net/url" + "strings" + "testing" +) + +func TestFormBody(t *testing.T) { + req, err := NewDefaultAPI(nil).LoginRequest(context.Background(), LoginParams{ + Username: "ann", + Password: "s3cret&", + RememberMe: Ptr(true), + Tags: []string{"a", "b"}, + }) + if err != nil { + t.Fatal(err) + } + + if ct := req.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" { + t.Errorf("Content-Type = %q", ct) + } + + body, _ := io.ReadAll(req.Body) + + form, err := url.ParseQuery(string(body)) + if err != nil { + t.Fatal(err) + } + + for name, want := range map[string]string{"username": "ann", "password": "s3cret&", "rememberMe": "true"} { + if got := form.Get(name); got != want { + t.Errorf("%s = %q, want %q", name, got, want) + } + } + + // An array is exploded by default, one key per value. + if got := form["tags"]; len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Errorf("tags = %q, want [a b]", got) + } +} + +// TestFormWithFile: only multipart carries a file, so a form declared +// url-encoded goes as multipart once it holds one. +func TestFormWithFile(t *testing.T) { + req, err := NewDefaultAPI(nil).UploadAvatarRequest(context.Background(), UploadAvatarParams{ + User: "ann", + Image: File{Name: "a.png", Body: strings.NewReader("PNG")}, + }) + if err != nil { + t.Fatal(err) + } + + _, params, err := mime.ParseMediaType(req.Header.Get("Content-Type")) + if err != nil { + t.Fatal(err) + } + + form, err := multipart.NewReader(req.Body, params["boundary"]).ReadForm(1 << 20) + if err != nil { + t.Fatalf("Content-Type %q: %v", req.Header.Get("Content-Type"), err) + } + + if got := form.Value["user"]; len(got) != 1 || got[0] != "ann" { + t.Errorf("user = %q", got) + } + + if f := form.File["image"]; len(f) != 1 || f[0].Filename != "a.png" || f[0].Size != 3 { + t.Errorf("image = %+v", f) + } +} diff --git a/testdata/gofetch/request-body-json/body_test.go b/testdata/gofetch/request-body-json/body_test.go new file mode 100644 index 0000000..94eb01c --- /dev/null +++ b/testdata/gofetch/request-body-json/body_test.go @@ -0,0 +1,67 @@ +package requestbodyjson + +import ( + "context" + "io" + "testing" +) + +func TestJSONBodies(t *testing.T) { + api := NewDefaultAPI(nil) + ctx := context.Background() + + req, err := api.CreateItemRequest(ctx, CreateItemParams{Item: Item{Name: "pen"}}) + if err != nil { + t.Fatal(err) + } + + if body, _ := io.ReadAll(req.Body); string(body) != `{"name":"pen"}` || req.Header.Get("Content-Type") != "application/json" { + t.Errorf("CreateItem body %s, content type %q", body, req.Header.Get("Content-Type")) + } + + req, err = api.CreateItemsRequest(ctx, CreateItemsParams{Item: []Item{{Name: "a", Quantity: Ptr(2)}}}) + if err != nil { + t.Fatal(err) + } + + if body, _ := io.ReadAll(req.Body); string(body) != `[{"name":"a","quantity":2}]` { + t.Errorf("CreateItems body %s", body) + } + + // A nil slice is no body, not null. + req, err = api.CreateItemsRequest(ctx, CreateItemsParams{}) + if err != nil || req.Body != nil { + t.Errorf("CreateItems without items: body %v, %v", req.Body, err) + } + + // A required nullable body is a pointer: nil sends null. + for body, want := range map[*ReplaceItemBody]string{nil: "null", {Name: Ptr("b")}: `{"name":"b"}`} { + req, err = api.ReplaceItemRequest(ctx, ReplaceItemParams{ID: "1", Body: body}) + if err != nil { + t.Fatal(err) + } + + if got, _ := io.ReadAll(req.Body); string(got) != want { + t.Errorf("ReplaceItem body %s, want %s", got, want) + } + } + + // So is an any body: nil is null, not none. + req, err = api.SetNoteRequest(ctx, SetNoteParams{ID: "1"}) + if err != nil { + t.Fatal(err) + } + + if got, _ := io.ReadAll(req.Body); string(got) != "null" { + t.Errorf("SetNote body %s, want null", got) + } + + req, err = api.SetRawCountRequest(ctx, SetRawCountParams{Body: 3}) + if err != nil { + t.Fatal(err) + } + + if body, _ := io.ReadAll(req.Body); string(body) != `3` { + t.Errorf("SetRawCount body %s", body) + } +} diff --git a/testdata/gofetch/request-body-multipart/body_test.go b/testdata/gofetch/request-body-multipart/body_test.go new file mode 100644 index 0000000..b5b02f8 --- /dev/null +++ b/testdata/gofetch/request-body-multipart/body_test.go @@ -0,0 +1,51 @@ +package requestbodymultipart + +import ( + "context" + "io" + "strings" + "testing" +) + +func TestMultipartBody(t *testing.T) { + req, err := NewDefaultAPI(nil).UploadFileRequest(context.Background(), UploadFileParams{ + File: File{Name: "a.txt", Body: strings.NewReader("hello")}, + Attachments: []File{{Name: "b.txt", Body: strings.NewReader("b")}, {Name: "c.txt", Body: strings.NewReader("c")}}, + Description: Ptr("two files"), + }) + if err != nil { + t.Fatal(err) + } + + if !strings.HasPrefix(req.Header.Get("Content-Type"), "multipart/form-data; boundary=") { + t.Errorf("Content-Type = %q", req.Header.Get("Content-Type")) + } + + if err := req.ParseMultipartForm(1 << 20); err != nil { + t.Fatal(err) + } + + form := req.MultipartForm + if got := form.Value["description"]; len(got) != 1 || got[0] != "two files" { + t.Errorf("description = %q", got) + } + + if got := form.File["attachments"]; len(got) != 2 || got[0].Filename != "b.txt" || got[1].Filename != "c.txt" { + t.Errorf("attachments = %v", got) + } + + files := form.File["file"] + if len(files) != 1 || files[0].Filename != "a.txt" { + t.Fatalf("file = %v", files) + } + + f, err := files[0].Open() + if err != nil { + t.Fatal(err) + } + defer f.Close() + + if data, _ := io.ReadAll(f); string(data) != "hello" { + t.Errorf("file content = %q", data) + } +} diff --git a/testdata/gofetch/request-body-other-media/body_test.go b/testdata/gofetch/request-body-other-media/body_test.go new file mode 100644 index 0000000..ded50d2 --- /dev/null +++ b/testdata/gofetch/request-body-other-media/body_test.go @@ -0,0 +1,127 @@ +package requestbodyothermedia + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// echo answers with the request's body and records the request. +type echo struct{ req *http.Request } + +func (e *echo) Do(req *http.Request) (*http.Response, error) { + e.req = req + + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + + h := http.Header{"Content-Type": {req.Header.Get("Content-Type")}} + + return &http.Response{StatusCode: 200, Header: h, Body: io.NopCloser(bytes.NewReader(body))}, nil +} + +func TestTextAndRawBodies(t *testing.T) { + e := &echo{} + api := NewDefaultAPI(&Configuration{HTTPClient: e}) + + note, err := api.PostNote(t.Context(), PostNoteParams{Body: "line 1\nčo"}) + if err != nil || note != "line 1\nčo" || e.req.Header.Get("Content-Type") != "text/plain" { + t.Errorf("text: %q, %v, %s", note, err, e.req.Header.Get("Content-Type")) + } + + blob, err := api.PutBlob(t.Context(), PutBlobParams{Body: bytes.NewReader([]byte{0, 0xff})}) + if err != nil || !bytes.Equal(blob, []byte{0, 0xff}) || e.req.Header.Get("Content-Type") != "application/octet-stream" { + t.Errorf("raw: %v, %v", blob, err) + } +} + +func TestMultipartObjectPart(t *testing.T) { + e := &echo{} + api := NewDefaultAPI(&Configuration{HTTPClient: e}) + + err := api.PostReport(t.Context(), PostReportParams{ + Title: "q3", + Meta: &Meta{Author: Ptr("ann"), Pages: Ptr(3)}, + Attachments: []File{{Name: "a.txt", Body: strings.NewReader("a")}, {Body: strings.NewReader("b")}}, + }) + if err != nil { + t.Fatal(err) + } + + req, _ := http.NewRequest(http.MethodPost, "/", bytes.NewReader(e.body(t))) + req.Header = e.req.Header + + if err := req.ParseMultipartForm(1 << 20); err != nil { + t.Fatal(err) + } + + // A File without a Name is named after its field. + f := req.MultipartForm + if f.Value["title"][0] != "q3" || f.Value["meta"][0] != `{"author":"ann","pages":3}` || + len(f.File["attachments"]) != 2 || f.File["attachments"][1].Filename != "attachments" { + t.Errorf("form = %v, files %v", f.Value, f.File["attachments"]) + } +} + +// body is the body the last request sent, read again. +func (e *echo) body(t *testing.T) []byte { + t.Helper() + + b, err := e.req.GetBody() + if err != nil { + t.Fatal(err) + } + + data, _ := io.ReadAll(b) + + return data +} + +// TestConfiguration checks BasePath, the default headers, the bearer token +// and that an editor's error stops the request. +func TestConfiguration(t *testing.T) { + var got *http.Request + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + + cfg := &Configuration{ + BasePath: srv.URL + "/v2", + Headers: http.Header{"X-Trace": {"t1"}}, + AccessToken: func(_ context.Context, op, name string, _ []string) (string, error) { + return op + ":" + name, nil + }, + } + + if _, err := NewDefaultAPI(cfg).PostNote(t.Context(), PostNoteParams{Body: "x"}); err != nil { + t.Fatal(err) + } + + if got.URL.Path != "/v2/notes" || got.Header.Get("X-Trace") != "t1" || got.Header.Get("Authorization") != "Bearer postNote:bearer" { + t.Errorf("request %s %v", got.URL.Path, got.Header) + } + + stop := errors.New("stop") + _, err := NewDefaultAPI(cfg).PostNote(t.Context(), PostNoteParams{Body: "x"}, func(context.Context, *http.Request) error { return stop }) + + if !errors.Is(err, stop) { + t.Errorf("editor error = %v", err) + } + + // The spec's server, when BasePath is not set. + req, err := NewDefaultAPI(nil).PutBlobRequest(t.Context(), PutBlobParams{Body: strings.NewReader("")}) + if err != nil || req.URL.String() != "http://api.example.com/v1/blobs" { + t.Errorf("default base: %v, %v", req.URL, err) + } +} diff --git a/testdata/gofetch/responses-binary/responses_test.go b/testdata/gofetch/responses-binary/responses_test.go new file mode 100644 index 0000000..6c01109 --- /dev/null +++ b/testdata/gofetch/responses-binary/responses_test.go @@ -0,0 +1,34 @@ +package responsesbinary + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestBinary(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write([]byte{0, 1, 2}) + })) + defer srv.Close() + + api := NewDefaultAPI(&Configuration{BasePath: srv.URL}) + + data, err := api.DownloadFile(context.Background(), DownloadFileParams{ID: "1"}) + if err != nil || string(data) != "\x00\x01\x02" { + t.Errorf("DownloadFile = %v, %v", data, err) + } + + resp, err := api.GetThumbnailRaw(context.Background(), GetThumbnailParams{ID: "1"}) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if data, _ := io.ReadAll(resp.Body); len(data) != 3 { + t.Errorf("GetThumbnailRaw body = %v", data) + } +} diff --git a/testdata/gofetch/responses-mixed-2xx/responses_test.go b/testdata/gofetch/responses-mixed-2xx/responses_test.go new file mode 100644 index 0000000..b6fa3e4 --- /dev/null +++ b/testdata/gofetch/responses-mixed-2xx/responses_test.go @@ -0,0 +1,61 @@ +package responsesmixed2xx + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMixed2xx(t *testing.T) { + status, ctype, body := 0, "", "" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", ctype) + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + api := NewDefaultAPI(&Configuration{BasePath: srv.URL}) + + status, ctype, body = 201, "application/json", `{"id":7}` + if o, err := api.PlaceOrder(t.Context()); err != nil || o.ID != 7 { + t.Errorf("201: %+v, %v", o, err) + } + + // The 202 body is a Problem, which an Order cannot hold. + status, body = 202, `{"title":"queued","status":202}` + _, err := api.PlaceOrder(t.Context()) + + var rerr *ResponseError + if !errors.As(err, &rerr) || rerr.StatusCode != 202 { + t.Fatalf("202: %v", err) + } + + if p, ok := rerr.Model.(*Problem); !ok || p.Title != "queued" { + t.Errorf("202 model = %#v", rerr.Model) + } + + status, ctype, body = 200, "application/x-ndjson", "{\"i\":0}\n{\"i\":1}\n" + if s, err := api.StreamLines(t.Context()); err != nil || s != body { + t.Errorf("ndjson: %#v, %v", s, err) + } + + status, ctype, body = 200, "application/json", "null" + if o, err := api.LatestOrder(t.Context()); err != nil || o != nil { + t.Errorf("null: %+v, %v", o, err) + } + + // A nullable string sent as text stays a string. + status, ctype, body = 200, "text/plain", "hello" + if s, err := api.Greeting(t.Context()); err != nil || s != "hello" { + t.Errorf("text: %#v, %v", s, err) + } + + // An any result holds every 2xx body. + status, ctype, body = 201, "application/json", `{"id":1}` + if v, err := api.Anything(t.Context()); err != nil || v == nil { + t.Errorf("any 201: %#v, %v", v, err) + } +} diff --git a/testdata/gofetch/responses-oneof/union_test.go b/testdata/gofetch/responses-oneof/union_test.go new file mode 100644 index 0000000..2a35853 --- /dev/null +++ b/testdata/gofetch/responses-oneof/union_test.go @@ -0,0 +1,30 @@ +package responsesoneof + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestOneOfResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"accountType":"business","companyName":"Acme"}`)) + })) + defer srv.Close() + + acc, err := NewDefaultAPI(&Configuration{BasePath: srv.URL}).GetAccount(context.Background(), GetAccountParams{ID: "1"}) + if err != nil { + t.Fatal(err) + } + + if b, err := acc.AsBusinessAccount(); err != nil || b.CompanyName != "Acme" { + t.Errorf("AsBusinessAccount = %+v, %v", b, err) + } + + // A business account lacks ownerName, which a personal one requires. + if p, err := acc.AsPersonalAccount(); err == nil { + t.Errorf("AsPersonalAccount = %+v, want an error", p) + } +} diff --git a/testdata/gofetch/responses-status-codes/responses_test.go b/testdata/gofetch/responses-status-codes/responses_test.go new file mode 100644 index 0000000..67ba15f --- /dev/null +++ b/testdata/gofetch/responses-status-codes/responses_test.go @@ -0,0 +1,62 @@ +package responsesstatuscodes + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestStatusCodes(t *testing.T) { + status, body := 0, "" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + api := NewDefaultAPI(&Configuration{BasePath: srv.URL}) + ctx := context.Background() + + status, body = 200, `{"id":"1","title":"t"}` + if task, err := api.GetTask(ctx, GetTaskParams{ID: "1"}); err != nil || *task.Title != "t" { + t.Errorf("200: %+v, %v", task, err) + } + + for _, code := range []int{404, 500} { + status, body = code, `{"message":"nope"}` + _, err := api.GetTask(ctx, GetTaskParams{ID: "1"}) + + var rerr *ResponseError + if !errors.As(err, &rerr) || rerr.StatusCode != code || string(rerr.Body) != body { + t.Fatalf("%d: error %v", code, err) + } + + if m, ok := rerr.Model.(*ErrorBody); !ok || *m.Message != "nope" { + t.Errorf("%d: model = %#v", code, rerr.Model) + } + } + + status, body = 204, "" + if err := api.DeleteTask(ctx, DeleteTaskParams{ID: "1"}); err != nil { + t.Errorf("204: %v", err) + } + + status, body = 201, `{"id":"1"}` + if task, err := api.CreateTask(ctx, CreateTaskParams{ID: "1"}); err != nil || task == nil || *task.ID != "1" { + t.Errorf("201: %+v, %v", task, err) + } + + status, body = 202, "" + if task, err := api.CreateTask(ctx, CreateTaskParams{ID: "1"}); err != nil || task != nil { + t.Errorf("202 without a body: %+v, %v", task, err) + } + + status, body = 409, "conflict" + if err := api.DeleteTask(ctx, DeleteTaskParams{ID: "1"}); err == nil || err.Error() != "409 Conflict" { + t.Errorf("409: %v", err) + } +} diff --git a/testdata/gofetch/responses-stream/stream_test.go b/testdata/gofetch/responses-stream/stream_test.go new file mode 100644 index 0000000..1176185 --- /dev/null +++ b/testdata/gofetch/responses-stream/stream_test.go @@ -0,0 +1,110 @@ +package responsesstream + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func serve(t *testing.T, contentType, body string) *DefaultAPI { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", contentType) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + + return NewDefaultAPI(&Configuration{BasePath: srv.URL}) +} + +func TestJSONLines(t *testing.T) { + ticks, err := serve(t, "application/jsonl", "{\"i\":0,\"label\":\"a\"}\n{\"i\":1}\n").StreamTicks(context.Background()) + if err != nil { + t.Fatal(err) + } + + var got []Tick + + for tick, err := range ticks { + if err != nil { + t.Fatal(err) + } + + got = append(got, tick) + } + + if len(got) != 2 || got[0].I != 0 || *got[0].Label != "a" || got[1].I != 1 { + t.Errorf("ticks = %+v", got) + } + + // A bad line ends the range with its error; a break stops it early. + words, _ := serve(t, "application/jsonl", "\"a\"\n\"b\"\nnot json\n\"c\"\n").StreamWords(context.Background()) + + var n int + + for _, err := range words { + if n++; err != nil { + break + } + } + + if n != 3 { + t.Errorf("words read until the error = %d, want 3", n) + } + + for range words { + break + } +} + +func TestServerSentEvents(t *testing.T) { + body := ": ping\n\nevent: delta\nid: 1\ndata: {\"kind\":\"delta\",\n" + + "data: \"text\":\"hi\"}\n\nretry: 500\ndata: {\"kind\":\"done\",\"words\":1}\n\n" + + events, err := serve(t, "text/event-stream", body).Chat(context.Background(), ChatParams{Body: ChatBody{Prompt: "hi"}}) + if err != nil { + t.Fatal(err) + } + + var got []ServerSentEvent[ChatItem] + + for e, err := range events { + if err != nil { + t.Fatal(err) + } + + got = append(got, e) + } + + if len(got) != 2 || got[0].Event != "delta" || got[0].ID != "1" || got[1].Retry != 500 { + t.Fatalf("events = %+v", got) + } + + if d, err := got[0].Data.AsDelta(); err != nil || d.Text != "hi" { + t.Errorf("first data = %+v, %v", d, err) + } + + if d, err := got[1].Data.AsDone(); err != nil || d.Words != 1 { + t.Errorf("last data = %+v, %v", d, err) + } + + // Data of a string schema is the text itself; an id holds until the next + // one; a BOM is skipped; an event the body ends before is dropped. + raw, _ := serve(t, "text/event-stream", "\ufeffid: 7\ndata: {\"i\":1}\n\nevent: end\ndata: bye\n\ndata: cut").RawEvents(context.Background()) + + var texts []string + + for e, err := range raw { + if err != nil { + t.Fatal(err) + } + + texts = append(texts, e.ID+e.Event+"="+e.Data) + } + + if len(texts) != 2 || texts[0] != `7={"i":1}` || texts[1] != "7end=bye" { + t.Errorf("raw events = %q", texts) + } +} diff --git a/testdata/gofetch/security-apikey/auth_test.go b/testdata/gofetch/security-apikey/auth_test.go new file mode 100644 index 0000000..3dc4b9c --- /dev/null +++ b/testdata/gofetch/security-apikey/auth_test.go @@ -0,0 +1,52 @@ +package securityapikey + +import ( + "context" + "testing" +) + +func TestAPIKey(t *testing.T) { + var calls []string + + api := NewDefaultAPI(&Configuration{ + APIKey: func(_ context.Context, op, name string) (string, error) { + calls = append(calls, op+" "+name) + + return "k-" + name, nil + }, + }) + ctx := context.Background() + + req, err := api.GetWithHeaderKeyRequest(ctx) + if err != nil || req.Header.Get("X-Api-Key") != "k-X-Api-Key" { + t.Errorf("header key: %v, %v", req.Header, err) + } + + req, err = api.GetWithQueryKeyRequest(ctx) + if err != nil || req.URL.Query().Get("api_key") != "k-api_key" { + t.Errorf("query key: %v, %v", req.URL, err) + } + + req, err = api.GetWithCookieKeyRequest(ctx) + if c, cerr := req.Cookie("session"); err != nil || cerr != nil || c.Value != "k-session" { + t.Errorf("cookie key: %v, %v, %v", c, err, cerr) + } + + want := []string{"getWithHeaderKey X-Api-Key", "getWithQueryKey api_key", "getWithCookieKey session"} + if len(calls) != len(want) { + t.Fatalf("calls = %q, want %q", calls, want) + } + + for i := range want { + if calls[i] != want[i] { + t.Errorf("call %d = %q, want %q", i, calls[i], want[i]) + } + } +} + +func TestNoKeyConfigured(t *testing.T) { + req, err := NewDefaultAPI(nil).GetWithHeaderKeyRequest(context.Background()) + if err != nil || req.Header.Get("X-Api-Key") != "" { + t.Errorf("header = %v, err %v, want no key", req.Header, err) + } +} diff --git a/testdata/gofetch/security-apikey/empty_test.go b/testdata/gofetch/security-apikey/empty_test.go new file mode 100644 index 0000000..5f6cec9 --- /dev/null +++ b/testdata/gofetch/security-apikey/empty_test.go @@ -0,0 +1,24 @@ +package securityapikey + +import ( + "context" + "errors" + "testing" +) + +// TestEmptyOrFailingKey sends no key for an empty one and stops on an error. +func TestEmptyOrFailingKey(t *testing.T) { + empty := NewDefaultAPI(&Configuration{APIKey: func(context.Context, string, string) (string, error) { return "", nil }}) + + req, err := empty.GetWithQueryKeyRequest(t.Context()) + if err != nil || req.URL.RawQuery != "" { + t.Errorf("empty key: %q, %v", req.URL.RawQuery, err) + } + + boom := errors.New("vault down") + failing := NewDefaultAPI(&Configuration{APIKey: func(context.Context, string, string) (string, error) { return "", boom }}) + + if _, err := failing.GetWithHeaderKeyRequest(t.Context()); !errors.Is(err, boom) { + t.Errorf("failing key: %v", err) + } +} diff --git a/testdata/gofetch/security-http/auth_test.go b/testdata/gofetch/security-http/auth_test.go new file mode 100644 index 0000000..c295645 --- /dev/null +++ b/testdata/gofetch/security-http/auth_test.go @@ -0,0 +1,35 @@ +package securityhttp + +import ( + "context" + "testing" +) + +func TestHTTPAuth(t *testing.T) { + var scheme string + + api := NewDefaultAPI(&Configuration{ + Username: "u", + Password: "p", + AccessToken: func(_ context.Context, op, name string, scopes []string) (string, error) { + scheme = op + " " + name + + return "tok", nil + }, + }) + ctx := context.Background() + + req, err := api.GetWithBasicAuthRequest(ctx) + if user, pass, ok := req.BasicAuth(); err != nil || !ok || user != "u" || pass != "p" { + t.Errorf("basic auth = %q %q %v, %v", user, pass, ok, err) + } + + req, err = api.GetWithBearerAuthRequest(ctx) + if err != nil || req.Header.Get("Authorization") != "Bearer tok" { + t.Errorf("bearer: %v, %v", req.Header, err) + } + + if scheme != "getWithBearerAuth BearerAuth" { + t.Errorf("AccessToken called with %q", scheme) + } +} diff --git a/testdata/gofetch/security-oauth2/auth_test.go b/testdata/gofetch/security-oauth2/auth_test.go new file mode 100644 index 0000000..c14209d --- /dev/null +++ b/testdata/gofetch/security-oauth2/auth_test.go @@ -0,0 +1,28 @@ +package securityoauth2 + +import ( + "context" + "reflect" + "testing" +) + +func TestOAuth2Scopes(t *testing.T) { + var got []string + + api := NewDefaultAPI(&Configuration{ + AccessToken: func(_ context.Context, op, name string, scopes []string) (string, error) { + got = append([]string{op, name}, scopes...) + + return "tok", nil + }, + }) + + req, err := api.GetWithOAuthCodeRequest(context.Background()) + if err != nil || req.Header.Get("Authorization") != "Bearer tok" { + t.Errorf("oauth2: %v, %v", req.Header, err) + } + + if want := []string{"getWithOAuthCode", "OAuth2Code", "read", "write"}; !reflect.DeepEqual(got, want) { + t.Errorf("AccessToken called with %q, want %q", got, want) + } +} diff --git a/testdata/specs/feature/additional-properties-with-properties.yaml b/testdata/specs/feature/additional-properties-with-properties.yaml new file mode 100644 index 0000000..5e87739 --- /dev/null +++ b/testdata/specs/feature/additional-properties-with-properties.yaml @@ -0,0 +1,49 @@ +openapi: "3.0.0" +info: + title: Additional properties next to properties + version: 1.0.0 + description: Objects that declare some properties and take any other key too (pydantic extra="allow"), or only keys of one type. +paths: + /events: + post: + operationId: recordEvent + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Event" + responses: + "200": + description: the event as stored + content: + application/json: + schema: + $ref: "#/components/schemas/Scores" +components: + schemas: + Event: + type: object + required: [kind] + properties: + kind: + type: string + at: + type: string + format: date-time + additionalProperties: true + Notes: + type: object + properties: + title: + type: string + additionalProperties: + type: string + Scores: + type: object + required: [player] + properties: + player: + type: string + additionalProperties: + type: integer diff --git a/testdata/specs/feature/composition-oneof-inline.yaml b/testdata/specs/feature/composition-oneof-inline.yaml new file mode 100644 index 0000000..bf6d51a --- /dev/null +++ b/testdata/specs/feature/composition-oneof-inline.yaml @@ -0,0 +1,80 @@ +openapi: 3.0.3 +info: + title: Inline oneOf + version: 1.0.0 + description: oneOf and anyOf written in place, in a property, an array item, a request body and a response, not as a schema of their own; the members tell themselves apart by a kind of one value, or are objects of their own. +paths: + /shelters/{id}/pets: + post: + operationId: admit + parameters: + - name: id + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/Cat" + - $ref: "#/components/schemas/Dog" + responses: + "200": + description: the shelter after admission + content: + application/json: + schema: { $ref: "#/components/schemas/Shelter" } + /pets/random: + get: + operationId: randomPet + responses: + "200": + description: a cat or a dog + content: + application/json: + schema: + anyOf: + - $ref: "#/components/schemas/Cat" + - $ref: "#/components/schemas/Dog" +components: + schemas: + Cat: + type: object + required: [kind, lives] + properties: + kind: { type: string, enum: [cat] } + lives: { type: integer } + Dog: + type: object + required: [kind, barkVolume] + properties: + kind: { type: string, enum: [dog] } + barkVolume: { type: number } + Shelter: + type: object + required: [favourite] + properties: + favourite: + description: The pet everyone likes. + oneOf: + - $ref: "#/components/schemas/Cat" + - $ref: "#/components/schemas/Dog" + location: + oneOf: + - type: object + required: [street] + properties: + street: { type: string } + - type: object + required: [lat, lon] + properties: + lat: { type: number } + lon: { type: number } + residents: + type: array + items: + anyOf: + - $ref: "#/components/schemas/Cat" + - $ref: "#/components/schemas/Dog" diff --git a/testdata/specs/feature/discriminator-oneof.yaml b/testdata/specs/feature/discriminator-oneof.yaml new file mode 100644 index 0000000..6f12fb3 --- /dev/null +++ b/testdata/specs/feature/discriminator-oneof.yaml @@ -0,0 +1,44 @@ +openapi: "3.0.0" +info: + title: OneOf Discriminator Feature + version: 1.0.0 +paths: + /pets: + get: + operationId: getPet + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" +components: + schemas: + # two mapping values name the same model + Pet: + oneOf: + - $ref: "#/components/schemas/Cat" + - $ref: "#/components/schemas/Dog" + discriminator: + propertyName: petType + mapping: + cat: "#/components/schemas/Cat" + dog: "#/components/schemas/Dog" + canine: "#/components/schemas/Dog" + Cat: + type: object + required: [petType] + properties: + petType: + type: string + huntingSkill: + type: string + Dog: + type: object + required: [petType] + properties: + petType: + type: string + packSize: + type: integer diff --git a/testdata/specs/feature/nullable-openapi31.yaml b/testdata/specs/feature/nullable-openapi31.yaml new file mode 100644 index 0000000..fd09535 --- /dev/null +++ b/testdata/specs/feature/nullable-openapi31.yaml @@ -0,0 +1,95 @@ +openapi: 3.1.0 +info: + title: OpenAPI 3.1 nullability + version: 1.0.0 + description: The 3.1 spellings of null (a null member of anyOf/oneOf, a type list with null) and of a binary string (contentMediaType), and of a single value (const) or a choice of them, a null among enum values, a union with a member that takes null, as FastAPI and pydantic write them; multipart arrays, exploded by default. +paths: + /pets: + get: + operationId: listPets + parameters: + - name: q + in: query + schema: + anyOf: [{ type: string }, { type: "null" }] + - name: size + in: query + schema: + anyOf: [{ type: array, items: { $ref: "#/components/schemas/Size" } }, { type: "null" }] + responses: + "200": + description: ok + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/Pet" } + /pets/{id}/photo: + post: + operationId: uploadPhoto + parameters: + - name: id + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: { type: string, contentMediaType: application/octet-stream } + caption: + anyOf: [{ type: string }, { type: "null" }] + tags: + type: array + items: { type: string } + labels: + type: array + items: { type: string } + encoding: + labels: + explode: false + responses: + "204": + description: stored +components: + schemas: + Size: + type: integer + enum: [1, 2, 3] + Owner: + type: object + required: [name] + properties: + name: { type: string } + Pet: + type: object + required: [kind, name, size] + properties: + kind: { type: string, const: pet } + name: { type: string } + size: + anyOf: [{ $ref: "#/components/schemas/Size" }, { type: "null" }] + owner: + description: Who keeps the pet. + anyOf: [{ $ref: "#/components/schemas/Owner" }, { type: "null" }] + born: + anyOf: [{ type: string, format: date }, { type: "null" }] + nickname: + type: [string, "null"] + tags: + anyOf: [{ type: array, items: { type: string } }, { type: "null" }] + alias: + $ref: "#/components/schemas/MaybeName" + mood: + anyOf: [{ type: string, const: happy }, { type: string, const: sad }] + tag: + type: string + enum: [a, null] + expires: + oneOf: [{ type: [string, "null"], format: date }, { type: integer }] + MaybeName: + anyOf: [{ type: string }, { type: "null" }] diff --git a/testdata/specs/feature/parameters-object-styles.yaml b/testdata/specs/feature/parameters-object-styles.yaml new file mode 100644 index 0000000..25480e4 --- /dev/null +++ b/testdata/specs/feature/parameters-object-styles.yaml @@ -0,0 +1,34 @@ +openapi: "3.0.0" +info: + title: Object and array parameters in every style + version: 1.0.0 + description: An object (and an array) as a path, header, query and cookie parameter, in each style the OpenAPI serialization table lists, exploded and not. +paths: + /simple/{a}/{b}/label/{c}/{d}/matrix/{e}/{f}/{g}: + get: + operationId: paint + parameters: + - { name: a, in: path, required: true, style: simple, explode: false, schema: { $ref: "#/components/schemas/Color" } } + - { name: b, in: path, required: true, style: simple, explode: true, schema: { $ref: "#/components/schemas/Color" } } + - { name: c, in: path, required: true, style: label, explode: false, schema: { $ref: "#/components/schemas/Color" } } + - { name: d, in: path, required: true, style: label, explode: true, schema: { $ref: "#/components/schemas/Color" } } + - { name: e, in: path, required: true, style: matrix, explode: false, schema: { $ref: "#/components/schemas/Color" } } + - { name: f, in: path, required: true, style: matrix, explode: true, schema: { $ref: "#/components/schemas/Color" } } + - { name: g, in: path, required: true, style: matrix, explode: true, schema: { type: array, items: { type: string } } } + - { name: q, in: query, style: form, explode: false, schema: { $ref: "#/components/schemas/Color" } } + - { name: X-Color, in: header, schema: { $ref: "#/components/schemas/Color" } } + - { name: X-Color-Exploded, in: header, explode: true, schema: { $ref: "#/components/schemas/Color" } } + - { name: X-Tags, in: header, schema: { type: array, items: { type: string } } } + - { name: prefs, in: cookie, schema: { type: array, items: { type: string } } } + responses: + "204": + description: painted +components: + schemas: + Color: + type: object + required: [R, G, B] + properties: + R: { type: integer } + G: { type: integer } + B: { type: integer } diff --git a/testdata/specs/feature/parameters-scalar-formats.yaml b/testdata/specs/feature/parameters-scalar-formats.yaml new file mode 100644 index 0000000..425a12c --- /dev/null +++ b/testdata/specs/feature/parameters-scalar-formats.yaml @@ -0,0 +1,21 @@ +openapi: "3.0.0" +info: + title: Scalar parameter formats + version: 1.0.0 + description: Query parameters of each scalar type and format, as their text on the wire. +paths: + /search: + get: + operationId: search + parameters: + - { name: f32, in: query, schema: { type: number, format: float } } + - { name: f64, in: query, schema: { type: number, format: double } } + - { name: n, in: query, schema: { type: number } } + - { name: i64, in: query, schema: { type: integer, format: int64 } } + - { name: ok, in: query, schema: { type: boolean } } + - { name: day, in: query, schema: { type: string, format: date } } + - { name: at, in: query, schema: { type: string, format: date-time } } + - { name: raw, in: query, schema: { type: string, format: byte } } + responses: + "204": + description: ok diff --git a/testdata/specs/feature/request-body-form-encoding.yaml b/testdata/specs/feature/request-body-form-encoding.yaml new file mode 100644 index 0000000..2fbdbf5 --- /dev/null +++ b/testdata/specs/feature/request-body-form-encoding.yaml @@ -0,0 +1,58 @@ +openapi: "3.0.0" +info: + title: Form Body Encoding Feature + version: 1.0.0 +paths: + /customers: + post: + operationId: createCustomer + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [name] + properties: + name: + type: string + address: + type: object + properties: + city: + type: string + lines: + type: array + items: + type: string + metadata: + type: object + additionalProperties: + type: string + expand: + type: array + items: + type: string + tags: + type: array + items: + type: string + ids: + type: array + items: + type: integer + encoding: + address: + style: deepObject + explode: true + metadata: + style: deepObject + explode: true + expand: + style: deepObject + explode: true + tags: + explode: false + responses: + "204": + description: created diff --git a/testdata/specs/feature/request-body-form-urlencoded.yaml b/testdata/specs/feature/request-body-form-urlencoded.yaml index 4c5e82f..49cfa3f 100644 --- a/testdata/specs/feature/request-body-form-urlencoded.yaml +++ b/testdata/specs/feature/request-body-form-urlencoded.yaml @@ -27,3 +27,23 @@ paths: responses: "200": description: ok + /avatar: + post: + operationId: uploadAvatar + description: A form declared url-encoded that holds a file, as FastAPI writes a form model with an UploadFile. + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [user, image] + properties: + user: + type: string + image: + type: string + format: binary + responses: + "204": + description: stored diff --git a/testdata/specs/feature/request-body-json.yaml b/testdata/specs/feature/request-body-json.yaml index 9813fe0..eb0fd14 100644 --- a/testdata/specs/feature/request-body-json.yaml +++ b/testdata/specs/feature/request-body-json.yaml @@ -45,6 +45,34 @@ paths: responses: "200": description: ok + /items/{id}/patch: + put: + operationId: replaceItem + description: A required body that may be null, as GitHub's gists/update has it. + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: { type: object, nullable: true, properties: { name: { type: string } } } + responses: + "204": + description: replaced + /items/{id}/note: + put: + operationId: setNote + description: A required body of any JSON that may be null. + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: { nullable: true } + responses: + "204": + description: set components: schemas: Item: diff --git a/testdata/specs/feature/request-body-other-media.yaml b/testdata/specs/feature/request-body-other-media.yaml new file mode 100644 index 0000000..3c814ee --- /dev/null +++ b/testdata/specs/feature/request-body-other-media.yaml @@ -0,0 +1,80 @@ +openapi: "3.0.0" +info: + title: Request bodies that are not JSON + version: 1.0.0 + description: A text/plain body, a raw octet-stream body and a multipart body with an object part. +servers: + - url: http://api.example.com/v1 +paths: + /notes: + post: + operationId: postNote + security: + - bearer: [] + requestBody: + required: true + content: + text/plain: + schema: + type: string + responses: + "200": + description: the note as stored + content: + text/plain: + schema: + type: string + /blobs: + put: + operationId: putBlob + requestBody: + required: true + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + "200": + description: the blob back + content: + application/octet-stream: + schema: + type: string + format: binary + /reports: + post: + operationId: postReport + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [title] + properties: + title: + type: string + meta: + $ref: "#/components/schemas/Meta" + attachments: + type: array + items: + type: string + format: binary + responses: + "204": + description: stored +components: + securitySchemes: + bearer: + type: http + scheme: bearer + schemas: + Meta: + type: object + properties: + author: + type: string + pages: + type: integer diff --git a/testdata/specs/feature/responses-mixed-2xx.yaml b/testdata/specs/feature/responses-mixed-2xx.yaml new file mode 100644 index 0000000..bee4170 --- /dev/null +++ b/testdata/specs/feature/responses-mixed-2xx.yaml @@ -0,0 +1,88 @@ +openapi: "3.0.0" +info: + title: Mixed success responses + version: 1.0.0 + description: Success responses as frameworks write them, FastAPI among them; a 202 with another body than the 201, an empty JSON schema next to a text stream, a nullable result. +paths: + /orders: + post: + operationId: placeOrder + responses: + "201": + description: placed + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "202": + description: queued for review + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" + /stream: + get: + operationId: streamLines + responses: + "200": + description: one JSON object per line + content: + application/json: + schema: {} + application/x-ndjson: + schema: + type: string + /orders/latest: + get: + operationId: latestOrder + responses: + "200": + description: the latest order, null when there is none + content: + application/json: + schema: + nullable: true + allOf: + - $ref: "#/components/schemas/Order" + /greeting: + get: + operationId: greeting + responses: + "200": + description: a greeting, as text + content: + text/plain: + schema: + type: string + nullable: true + /anything: + get: + operationId: anything + responses: + "200": + description: any JSON + content: + application/json: + schema: {} + "201": + description: an order + content: + application/json: + schema: + $ref: "#/components/schemas/Order" +components: + schemas: + Order: + type: object + required: [id] + properties: + id: + type: integer + Problem: + type: object + required: [title, status] + properties: + title: + type: string + status: + type: integer diff --git a/testdata/specs/feature/responses-stream.yaml b/testdata/specs/feature/responses-stream.yaml new file mode 100644 index 0000000..7bf9529 --- /dev/null +++ b/testdata/specs/feature/responses-stream.yaml @@ -0,0 +1,118 @@ +openapi: 3.1.0 +info: + title: Streamed responses + version: 1.0.0 + description: Responses that stream their items, as FastAPI describes a yield endpoint with 3.2's itemSchema in a 3.1 document; JSON Lines of a model and of strings, server-sent events with JSON data of a union, with raw data and without a data schema; a JSON text sequence, which only its Raw method reads. +paths: + /ticks: + get: + operationId: streamTicks + responses: + "200": + description: one tick per line + content: + application/jsonl: + itemSchema: { $ref: "#/components/schemas/Tick" } + /words: + get: + operationId: streamWords + responses: + "200": + description: one word per line + content: + application/jsonl: + itemSchema: { type: string } + /chat: + post: + operationId: chat + requestBody: + required: true + content: + application/json: + schema: { type: object, required: [prompt], properties: { prompt: { type: string } } } + responses: + "200": + description: a delta per word, then done + content: + text/event-stream: + itemSchema: + type: object + required: [data] + properties: + data: + type: string + contentMediaType: application/json + contentSchema: + oneOf: [{ $ref: "#/components/schemas/Delta" }, { $ref: "#/components/schemas/Done" }] + discriminator: { propertyName: kind } + event: { type: string } + id: { type: string } + retry: { type: integer, minimum: 0 } + /events: + get: + operationId: rawEvents + responses: + "200": + description: events of any data + content: + text/event-stream: + itemSchema: + type: object + properties: + data: { type: string } + event: { type: string } + id: { type: string } + retry: { type: integer, minimum: 0 } + /pings: + get: + operationId: pings + responses: + "200": + description: events whose schema leaves out data + content: + text/event-stream: + itemSchema: { type: object, properties: { event: { type: string } } } + /maybe: + get: + operationId: maybeTicks + responses: + "200": + description: events of a tick or null, as pydantic writes Tick | None + content: + text/event-stream: + itemSchema: + type: object + properties: + data: + type: string + contentMediaType: application/json + contentSchema: { anyOf: [{ $ref: "#/components/schemas/Tick" }, { type: "null" }] } + /records: + get: + operationId: records + responses: + "200": + description: a JSON text sequence, not read + content: + application/json-seq: + itemSchema: { $ref: "#/components/schemas/Tick" } +components: + schemas: + Tick: + type: object + required: [i] + properties: + i: { type: integer } + label: { type: string } + Delta: + type: object + required: [kind, text] + properties: + kind: { type: string, const: delta } + text: { type: string } + Done: + type: object + required: [kind, words] + properties: + kind: { type: string, const: done } + words: { type: integer }