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 }}