From d90d71a082b36e4d8ffa80566eb76844ae626979 Mon Sep 17 00:00:00 2001 From: Jamie Tanna Date: Sat, 14 Jun 2025 13:52:18 +0100 Subject: [PATCH] feat(generate): allow generating Server URL boilerplate When working with a client generated by `oapi-codegen`, it can be a little awkward to manage changing URLs for the `client.WithBaseURL`. Although it's possible to manage these ourselves - with hardcoded values - it can be handy to have this pre-generated, especially if the input spec already defines `$.servers`. This introduces the capability to opt-in to the generation (as we try to make all changes opt-in where possible) of these. This is a little more complicated than ""just"" generating constants, as a Server object could introduce a templated URL. This requires we: - generate the `const`s for non-templated URLs - generate the more in-depth boilerplate for templated URLs We try and do most of the generating in the template, rather than in Go code, although it's not straightforward to generate the method parameters for the generated function (i.e. `NewServerUrlTheProductionAPIServer`) so we create a `genServerURLWithVariablesFunctionParams` function. We'll leave a few cases of additional validation or handling of edge cases to a follow-up. --- README.md | 65 +++++++++++++++++++ configuration-schema.json | 4 ++ examples/generate/serverurls/api.yaml | 47 ++++++++++++++ examples/generate/serverurls/cfg.yaml | 8 +++ examples/generate/serverurls/gen.go | 74 ++++++++++++++++++++++ examples/generate/serverurls/gen_test.go | 48 ++++++++++++++ examples/generate/serverurls/generate.go | 3 + pkg/codegen/codegen.go | 13 ++++ pkg/codegen/configuration.go | 2 + pkg/codegen/server_urls.go | 81 ++++++++++++++++++++++++ pkg/codegen/template_helpers.go | 23 +++++++ pkg/codegen/templates/server-urls.tmpl | 61 ++++++++++++++++++ 12 files changed, 429 insertions(+) create mode 100644 examples/generate/serverurls/api.yaml create mode 100644 examples/generate/serverurls/cfg.yaml create mode 100644 examples/generate/serverurls/gen.go create mode 100644 examples/generate/serverurls/gen_test.go create mode 100644 examples/generate/serverurls/generate.go create mode 100644 pkg/codegen/server_urls.go create mode 100644 pkg/codegen/templates/server-urls.tmpl diff --git a/README.md b/README.md index 20c6d8fcc7..bbeb6df669 100644 --- a/README.md +++ b/README.md @@ -1731,6 +1731,71 @@ func TestClient_canCall() { } ``` +### With Server URLs + +An OpenAPI specification makes it possible to denote Servers that a client can interact with, such as: + +```yaml +servers: +- url: https://development.gigantic-server.com/v1 + description: Development server +- url: https://{username}.gigantic-server.com:{port}/{basePath} + description: The production API server + variables: + username: + # note! no enum here means it is an open value + default: demo + description: this value is assigned by the service provider, in this example `gigantic-server.com` + port: + enum: + - '8443' + - '443' + default: '8443' + basePath: + # open meaning there is the opportunity to use special base paths as assigned by the provider, default is `v2` + default: v2 +``` + +It is possible to opt-in to the generation of these Server URLs with the following configuration: + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/HEAD/configuration-schema.json +package: serverurls +output: gen.go +generate: + # NOTE that this uses default settings - if you want to use initialisms to generate i.e. `ServerURLDevelopmentServer`, you should look up the `output-options.name-normalizer` configuration + server-urls: true +``` + +This will then generate the following boilerplate: + +```go +// (the below does not include comments that are generated) + +const ServerUrlDevelopmentServer = "https://development.gigantic-server.com/v1" + +type ServerUrlTheProductionAPIServerBasePathVariable string +const ServerUrlTheProductionAPIServerBasePathVariableDefault = "v2" + +type ServerUrlTheProductionAPIServerPortVariable string +const ServerUrlTheProductionAPIServerPortVariable8443 ServerUrlTheProductionAPIServerPortVariable = "8443" +const ServerUrlTheProductionAPIServerPortVariable443 ServerUrlTheProductionAPIServerPortVariable = "443" +const ServerUrlTheProductionAPIServerPortVariableDefault ServerUrlTheProductionAPIServerPortVariable = ServerUrlTheProductionAPIServerPortVariable8443 + +type ServerUrlTheProductionAPIServerUsernameVariable string +const ServerUrlTheProductionAPIServerUsernameVariableDefault = "demo" + +func ServerUrlTheProductionAPIServer(basePath ServerUrlTheProductionAPIServerBasePathVariable, port ServerUrlTheProductionAPIServerPortVariable, username ServerUrlTheProductionAPIServerUsernameVariable) (string, error) { + // ... +} +``` + +Notice that for URLs that are not templated, a simple `const` definition is created. + +However, for more complex URLs that defined `variables` in them, we generate the types (and any `enum` values or `default` values), and instead use a function to create the URL. + +For a complete example see [`examples/generate/serverurls`](examples/generate/serverurls). + ## Generating API models If you're looking to only generate the models for interacting with a remote service, for instance if you need to hand-roll the API client for whatever reason, you can do this as-is. diff --git a/configuration-schema.json b/configuration-schema.json index fefbc19c86..0b35d7e657 100644 --- a/configuration-schema.json +++ b/configuration-schema.json @@ -56,6 +56,10 @@ "embedded-spec": { "type": "boolean", "description": "EmbeddedSpec indicates whether to embed the swagger spec in the generated code" + }, + "server-urls": { + "type": "boolean", + "description": "Generate types for the `Server` definitions' URLs, instead of needing to provide your own values" } } }, diff --git a/examples/generate/serverurls/api.yaml b/examples/generate/serverurls/api.yaml new file mode 100644 index 0000000000..48695165ba --- /dev/null +++ b/examples/generate/serverurls/api.yaml @@ -0,0 +1,47 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Server URLs can be optionally generated +servers: +# adapted from https://spec.openapis.org/oas/v3.0.3#server-object +- url: https://development.gigantic-server.com/v1 + description: Development server +- url: https://staging.gigantic-server.com/v1 + description: Staging server +- url: https://api.gigantic-server.com/v1 + description: Production server +# adapted from https://spec.openapis.org/oas/v3.0.3#server-object +- url: https://{username}.gigantic-server.com:{port}/{basePath} + description: The production API server + variables: + username: + # note! no enum here means it is an open value + default: demo + description: this value is assigned by the service provider, in this example `gigantic-server.com` + port: + enum: + - '8443' + - '443' + default: '8443' + basePath: + # open meaning there is the opportunity to use special base paths as assigned by the provider, default is `v2` + default: v2 + # an example of a type that's defined, but doesn't have a default + noDefault: {} + # # TODO this conflict will cause broken generated code https://github.com/oapi-codegen/oapi-codegen/issues/2003 + # conflicting: + # enum: + # - 'default' + # - '443' + # default: 'default' +# clash with the previous definition of `Development server` to trigger a new name +- url: http://localhost:80 + description: Development server +# clash with the previous definition of `Development server` to trigger a new name (again) +- url: http://localhost:80 + description: Development server +# make sure that the lowercase `description` gets converted to an uppercase +- url: http://localhost:80 + description: some lowercase name +# there may be URLs on their own, without a `description` +- url: http://localhost:443 diff --git a/examples/generate/serverurls/cfg.yaml b/examples/generate/serverurls/cfg.yaml new file mode 100644 index 0000000000..8815cb6a68 --- /dev/null +++ b/examples/generate/serverurls/cfg.yaml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: serverurls +output: gen.go +generate: + server-urls: true +output-options: + # to make sure that all types are generated, even if they're unreferenced + skip-prune: true diff --git a/examples/generate/serverurls/gen.go b/examples/generate/serverurls/gen.go new file mode 100644 index 0000000000..5f9e069d3e --- /dev/null +++ b/examples/generate/serverurls/gen.go @@ -0,0 +1,74 @@ +// Package serverurls provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package serverurls + +import ( + "fmt" + "strings" +) + +// ServerUrlDevelopmentServer defines the Server URL for Development server +const ServerUrlDevelopmentServer = "https://development.gigantic-server.com/v1" + +// ServerUrlDevelopmentServer1 defines the Server URL for Development server +const ServerUrlDevelopmentServer1 = "http://localhost:80" + +// ServerUrlDevelopmentServer2 defines the Server URL for Development server +const ServerUrlDevelopmentServer2 = "http://localhost:80" + +// ServerUrlHttplocalhost443 defines the Server URL for +const ServerUrlHttplocalhost443 = "http://localhost:443" + +// ServerUrlProductionServer defines the Server URL for Production server +const ServerUrlProductionServer = "https://api.gigantic-server.com/v1" + +// ServerUrlSomeLowercaseName defines the Server URL for some lowercase name +const ServerUrlSomeLowercaseName = "http://localhost:80" + +// ServerUrlStagingServer defines the Server URL for Staging server +const ServerUrlStagingServer = "https://staging.gigantic-server.com/v1" + +// ServerUrlTheProductionAPIServerBasePathVariable is the `basePath` variable for ServerUrlTheProductionAPIServer +type ServerUrlTheProductionAPIServerBasePathVariable string + +// ServerUrlTheProductionAPIServerBasePathVariableDefault is the default value for the `basePath` variable for ServerUrlTheProductionAPIServer +const ServerUrlTheProductionAPIServerBasePathVariableDefault = "v2" + +// ServerUrlTheProductionAPIServerNoDefaultVariable is the `noDefault` variable for ServerUrlTheProductionAPIServer +type ServerUrlTheProductionAPIServerNoDefaultVariable string + +// ServerUrlTheProductionAPIServerPortVariable is the `port` variable for ServerUrlTheProductionAPIServer +type ServerUrlTheProductionAPIServerPortVariable string + +// ServerUrlTheProductionAPIServerPortVariable8443 is one of the accepted values for the `port` variable for ServerUrlTheProductionAPIServer +const ServerUrlTheProductionAPIServerPortVariable8443 ServerUrlTheProductionAPIServerPortVariable = "8443" + +// ServerUrlTheProductionAPIServerPortVariable443 is one of the accepted values for the `port` variable for ServerUrlTheProductionAPIServer +const ServerUrlTheProductionAPIServerPortVariable443 ServerUrlTheProductionAPIServerPortVariable = "443" + +// ServerUrlTheProductionAPIServerPortVariableDefault is the default choice, for the accepted values for the `port` variable for ServerUrlTheProductionAPIServer +const ServerUrlTheProductionAPIServerPortVariableDefault ServerUrlTheProductionAPIServerPortVariable = ServerUrlTheProductionAPIServerPortVariable8443 + +// ServerUrlTheProductionAPIServerUsernameVariable is the `username` variable for ServerUrlTheProductionAPIServer +type ServerUrlTheProductionAPIServerUsernameVariable string + +// ServerUrlTheProductionAPIServerUsernameVariableDefault is the default value for the `username` variable for ServerUrlTheProductionAPIServer +const ServerUrlTheProductionAPIServerUsernameVariableDefault = "demo" + +// NewServerUrlTheProductionAPIServer constructs the Server URL for The production API server, with the provided variables. +func NewServerUrlTheProductionAPIServer(basePath ServerUrlTheProductionAPIServerBasePathVariable, noDefault ServerUrlTheProductionAPIServerNoDefaultVariable, port ServerUrlTheProductionAPIServerPortVariable, username ServerUrlTheProductionAPIServerUsernameVariable) (string, error) { + u := "https://{username}.gigantic-server.com:{port}/{basePath}" + + u = strings.ReplaceAll(u, "{basePath}", string(basePath)) + u = strings.ReplaceAll(u, "{noDefault}", string(noDefault)) + // TODO in the future, this will validate that the value is part of the ServerUrlTheProductionAPIServerPortVariable enum + u = strings.ReplaceAll(u, "{port}", string(port)) + u = strings.ReplaceAll(u, "{username}", string(username)) + + if strings.Contains(u, "{") || strings.Contains(u, "}") { + return "", fmt.Errorf("after mapping variables, there were still `{` or `}` characters in the string: %#v", u) + } + + return u, nil +} diff --git a/examples/generate/serverurls/gen_test.go b/examples/generate/serverurls/gen_test.go new file mode 100644 index 0000000000..2a2ecfb41a --- /dev/null +++ b/examples/generate/serverurls/gen_test.go @@ -0,0 +1,48 @@ +package serverurls + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServerUrlTheProductionAPIServer(t *testing.T) { + t.Run("when no values are provided, it does not error", func(t *testing.T) { + serverUrl, err := NewServerUrlTheProductionAPIServer("", "", "", "") + require.NoError(t, err) + + assert.Equal(t, "https://.gigantic-server.com:/", serverUrl) + + // NOTE that ideally this should fail as it doesn't /seem/ to provide a valid URL, but it does seem to be valid + _, err = url.Parse(serverUrl) + require.NoError(t, err) + }) + + // TODO:when we validate enums, this will need more testing https://github.com/oapi-codegen/oapi-codegen/issues/2006 + t.Run("when values that are not part of the enum are provided, it does not error", func(t *testing.T) { + invalidPort := ServerUrlTheProductionAPIServerPortVariable("12345") + serverUrl, err := NewServerUrlTheProductionAPIServer( + ServerUrlTheProductionAPIServerBasePathVariableDefault, + ServerUrlTheProductionAPIServerNoDefaultVariable(""), + invalidPort, + ServerUrlTheProductionAPIServerUsernameVariableDefault, + ) + require.NoError(t, err) + + assert.Equal(t, "https://demo.gigantic-server.com:12345/v2", serverUrl) + }) + + t.Run("when default values are provided, it does not error", func(t *testing.T) { + serverUrl, err := NewServerUrlTheProductionAPIServer( + ServerUrlTheProductionAPIServerBasePathVariableDefault, + ServerUrlTheProductionAPIServerNoDefaultVariable(""), + ServerUrlTheProductionAPIServerPortVariableDefault, + ServerUrlTheProductionAPIServerUsernameVariableDefault, + ) + require.NoError(t, err) + + assert.Equal(t, "https://demo.gigantic-server.com:8443/v2", serverUrl) + }) +} diff --git a/examples/generate/serverurls/generate.go b/examples/generate/serverurls/generate.go new file mode 100644 index 0000000000..68ad5cfab5 --- /dev/null +++ b/examples/generate/serverurls/generate.go @@ -0,0 +1,3 @@ +package serverurls + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config cfg.yaml api.yaml diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index facddb914d..6e602ea05a 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -202,6 +202,14 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { MergeImports(xGoTypeImports, imprts) } + var serverURLsDefinitions string + if opts.Generate.ServerURLs { + serverURLsDefinitions, err = GenerateServerURLs(t, spec) + if err != nil { + return "", fmt.Errorf("error generating Server URLs: %w", err) + } + } + var irisServerOut string if opts.Generate.IrisServer { irisServerOut, err = GenerateIrisServer(t, ops) @@ -326,6 +334,11 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { return "", fmt.Errorf("error writing constants: %w", err) } + _, err = w.WriteString(serverURLsDefinitions) + if err != nil { + return "", fmt.Errorf("error writing Server URLs: %w", err) + } + _, err = w.WriteString(typeDefinitions) if err != nil { return "", fmt.Errorf("error writing type definitions: %w", err) diff --git a/pkg/codegen/configuration.go b/pkg/codegen/configuration.go index 5608e227cd..4479acc1b1 100644 --- a/pkg/codegen/configuration.go +++ b/pkg/codegen/configuration.go @@ -126,6 +126,8 @@ type GenerateOptions struct { Models bool `yaml:"models,omitempty"` // EmbeddedSpec indicates whether to embed the swagger spec in the generated code EmbeddedSpec bool `yaml:"embedded-spec,omitempty"` + // ServerURLs generates types for the `Server` definitions' URLs, instead of needing to provide your own values + ServerURLs bool `yaml:"server-urls,omitempty"` } func (oo GenerateOptions) Validate() map[string]string { diff --git a/pkg/codegen/server_urls.go b/pkg/codegen/server_urls.go new file mode 100644 index 0000000000..d10c2d9f60 --- /dev/null +++ b/pkg/codegen/server_urls.go @@ -0,0 +1,81 @@ +package codegen + +import ( + "fmt" + "strconv" + "text/template" + + "github.com/getkin/kin-openapi/openapi3" +) + +const serverURLPrefix = "ServerUrl" +const serverURLSuffixIterations = 10 + +// ServerObjectDefinition defines the definition of an OpenAPI Server object (https://spec.openapis.org/oas/v3.0.3#server-object) as it is provided to code generation in `oapi-codegen` +type ServerObjectDefinition struct { + // GoName is the name of the variable for this Server URL + GoName string + + // OAPISchema is the underlying OpenAPI representation of the Server + OAPISchema *openapi3.Server +} + +func GenerateServerURLs(t *template.Template, spec *openapi3.T) (string, error) { + names := make(map[string]*openapi3.Server) + + for _, server := range spec.Servers { + suffix := server.Description + if suffix == "" { + suffix = nameNormalizer(server.URL) + } + name := serverURLPrefix + UppercaseFirstCharacter(suffix) + name = nameNormalizer(name) + + // if this is the only type with this name, store it + if _, conflict := names[name]; !conflict { + names[name] = server + continue + } + + // otherwise, try appending a number to the name + saved := false + // NOTE that we start at 1 on purpose, as + // + // ... ServerURLDevelopmentServer + // ... ServerURLDevelopmentServer1` + // + // reads better than: + // + // ... ServerURLDevelopmentServer + // ... ServerURLDevelopmentServer0 + for i := 1; i < 1+serverURLSuffixIterations; i++ { + suffixed := name + strconv.Itoa(i) + // and then store it if there's no conflict + if _, suffixConflict := names[suffixed]; !suffixConflict { + names[suffixed] = server + saved = true + break + } + } + + if saved { + continue + } + + // otherwise, error + return "", fmt.Errorf("failed to create a unique name for the Server URL (%#v) with description (%#v) after %d iterations", server.URL, server.Description, serverURLSuffixIterations) + } + + keys := SortedMapKeys(names) + servers := make([]ServerObjectDefinition, len(keys)) + i := 0 + for _, k := range keys { + servers[i] = ServerObjectDefinition{ + GoName: k, + OAPISchema: names[k], + } + i++ + } + + return GenerateTemplates([]string{"server-urls.tmpl"}, t, servers) +} diff --git a/pkg/codegen/template_helpers.go b/pkg/codegen/template_helpers.go index b9efe2cf45..49ee3aba64 100644 --- a/pkg/codegen/template_helpers.go +++ b/pkg/codegen/template_helpers.go @@ -23,6 +23,7 @@ import ( "golang.org/x/text/cases" "golang.org/x/text/language" + "github.com/getkin/kin-openapi/openapi3" "github.com/oapi-codegen/oapi-codegen/v2/pkg/util" ) @@ -295,6 +296,26 @@ func stripNewLines(s string) string { return r.Replace(s) } +// genServerURLWithVariablesFunctionParams is a template helper method to generate the function parameters for the generated function for a Server object that contains `variables` (https://spec.openapis.org/oas/v3.0.3#server-object) +// +// goTypePrefix is the prefix being used to create underlying types in the template (likely the `ServerObjectDefinition.GoName`) +// variables are this `ServerObjectDefinition`'s variables for the Server object (likely the `ServerObjectDefinition.OAPISchema`) +func genServerURLWithVariablesFunctionParams(goTypePrefix string, variables map[string]*openapi3.ServerVariable) string { + keys := SortedMapKeys(variables) + + if len(variables) == 0 { + return "" + } + parts := make([]string, len(variables)) + + for i := range keys { + k := keys[i] + variableDefinitionPrefix := goTypePrefix + UppercaseFirstCharacter(k) + "Variable" + parts[i] = k + " " + variableDefinitionPrefix + } + return strings.Join(parts, ", ") +} + // TemplateFunctions is passed to the template engine, and we can call each // function here by keyName from the template code. var TemplateFunctions = template.FuncMap{ @@ -323,4 +344,6 @@ var TemplateFunctions = template.FuncMap{ "stripNewLines": stripNewLines, "sanitizeGoIdentity": SanitizeGoIdentity, "toGoComment": StringWithTypeNameToGoComment, + + "genServerURLWithVariablesFunctionParams": genServerURLWithVariablesFunctionParams, } diff --git a/pkg/codegen/templates/server-urls.tmpl b/pkg/codegen/templates/server-urls.tmpl new file mode 100644 index 0000000000..f3599e5fa6 --- /dev/null +++ b/pkg/codegen/templates/server-urls.tmpl @@ -0,0 +1,61 @@ +{{ range . }} +{{ if eq 0 (len .OAPISchema.Variables) }} +{{/* URLs without variables are straightforward, so we'll create them a constant */}} +// {{ .GoName }} defines the Server URL for {{ .OAPISchema.Description }} +const {{ .GoName}} = "{{ .OAPISchema.URL }}" +{{ else }} +{{/* URLs with variables are not straightforward, as we may need multiple types, and so will model them as a function */}} + +{{/* first, we'll start by generating requisite types */}} + +{{ $goName := .GoName }} +{{ range $k, $v := .OAPISchema.Variables }} + {{ $prefix := printf "%s%sVariable" $goName ($k | ucFirst) }} + // {{ $prefix }} is the `{{ $k }}` variable for {{ $goName }} + type {{ $prefix }} string + {{ range $v.Enum }} + {{/* TODO this may result in broken generated code if any of the `enum` values are the literal value `default` https://github.com/oapi-codegen/oapi-codegen/issues/2003 */}} + // {{ $prefix }}{{ . | ucFirst }} is one of the accepted values for the `{{ $k }}` variable for {{ $goName }} + const {{ $prefix }}{{ . | ucFirst }} {{ $prefix }} = "{{ . }}" + {{ end }} + + {{/* TODO we should introduce a `Valid() error` method to enums https://github.com/oapi-codegen/oapi-codegen/issues/2006 */}} + + {{ if $v.Default }} + {{ if gt (len $v.Enum) 0 }} + {{/* if we have an enum, we should use the type defined for it for its default value + and reference the constant we've already defined for the value */}} + {{/* TODO this may result in broken generated code if any of the `enum` values are the literal value `default` https://github.com/oapi-codegen/oapi-codegen/issues/2003 */}} + {{/* TODO this may result in broken generated code if the `default` isn't found in `enum` (which is an issue with the spec) https://github.com/oapi-codegen/oapi-codegen/issues/2007 */}} + // {{ $prefix }}Default is the default choice, for the accepted values for the `{{ $k }}` variable for {{ $goName }} + const {{ $prefix }}Default {{ $prefix }} = {{ $prefix }}{{ $v.Default | ucFirst }} + {{ else }} + // {{ $prefix }}Default is the default value for the `{{ $k }}` variable for {{ $goName }} + const {{ $prefix }}Default = "{{ $v.Default }}" + {{ end }} + {{ end }} +{{ end }} + + +// New{{ .GoName }} constructs the Server URL for {{ .OAPISchema.Description }}, with the provided variables. +func New{{ .GoName }}({{ genServerURLWithVariablesFunctionParams .GoName .OAPISchema.Variables }}) (string, error) { + u := "{{ .OAPISchema.URL }}" + + {{ range $k, $v := .OAPISchema.Variables }} + {{- $placeholder := printf "{%s}" $k -}} + {{- if gt (len $v.Enum) 0 -}} + {{/* TODO https://github.com/oapi-codegen/oapi-codegen/issues/2006 */}} + // TODO in the future, this will validate that the value is part of the {{ printf "%s%sVariable" $goName ($k | ucFirst) }} enum + {{ end -}} + u = strings.ReplaceAll(u, "{{ $placeholder }}", string({{ $k }})) + {{ end }} + + if strings.Contains(u, "{") || strings.Contains(u, "}") { + return "", fmt.Errorf("after mapping variables, there were still `{` or `}` characters in the string: %#v", u) + } + + return u, nil +} + +{{ end }} +{{ end }}