Skip to content

Commit aae687c

Browse files
committed
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.
1 parent b8ebad4 commit aae687c

12 files changed

Lines changed: 429 additions & 0 deletions

File tree

README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1731,6 +1731,71 @@ func TestClient_canCall() {
17311731
}
17321732
```
17331733

1734+
### With Server URLs
1735+
1736+
An OpenAPI specification makes it possible to denote Servers that a client can interact with, such as:
1737+
1738+
```yaml
1739+
servers:
1740+
- url: https://development.gigantic-server.com/v1
1741+
description: Development server
1742+
- url: https://{username}.gigantic-server.com:{port}/{basePath}
1743+
description: The production API server
1744+
variables:
1745+
username:
1746+
# note! no enum here means it is an open value
1747+
default: demo
1748+
description: this value is assigned by the service provider, in this example `gigantic-server.com`
1749+
port:
1750+
enum:
1751+
- '8443'
1752+
- '443'
1753+
default: '8443'
1754+
basePath:
1755+
# open meaning there is the opportunity to use special base paths as assigned by the provider, default is `v2`
1756+
default: v2
1757+
```
1758+
1759+
It is possible to opt-in to the generation of these Server URLs with the following configuration:
1760+
1761+
```yaml
1762+
# yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/HEAD/configuration-schema.json
1763+
package: serverurls
1764+
output: gen.go
1765+
generate:
1766+
# 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
1767+
server-urls: true
1768+
```
1769+
1770+
This will then generate the following boilerplate:
1771+
1772+
```go
1773+
// (the below does not include comments that are generated)
1774+
1775+
const ServerUrlDevelopmentServer = "https://development.gigantic-server.com/v1"
1776+
1777+
type ServerUrlTheProductionAPIServerBasePathVariable string
1778+
const ServerUrlTheProductionAPIServerBasePathVariableDefault = "v2"
1779+
1780+
type ServerUrlTheProductionAPIServerPortVariable string
1781+
const ServerUrlTheProductionAPIServerPortVariable8443 ServerUrlTheProductionAPIServerPortVariable = "8443"
1782+
const ServerUrlTheProductionAPIServerPortVariable443 ServerUrlTheProductionAPIServerPortVariable = "443"
1783+
const ServerUrlTheProductionAPIServerPortVariableDefault ServerUrlTheProductionAPIServerPortVariable = ServerUrlTheProductionAPIServerPortVariable8443
1784+
1785+
type ServerUrlTheProductionAPIServerUsernameVariable string
1786+
const ServerUrlTheProductionAPIServerUsernameVariableDefault = "demo"
1787+
1788+
func ServerUrlTheProductionAPIServer(basePath ServerUrlTheProductionAPIServerBasePathVariable, port ServerUrlTheProductionAPIServerPortVariable, username ServerUrlTheProductionAPIServerUsernameVariable) (string, error) {
1789+
// ...
1790+
}
1791+
```
1792+
1793+
Notice that for URLs that are not templated, a simple `const` definition is created.
1794+
1795+
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.
1796+
1797+
For a complete example see [`examples/generate/serverurls`](examples/generate/serverurls).
1798+
17341799
## Generating API models
17351800

17361801
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.

configuration-schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@
5656
"embedded-spec": {
5757
"type": "boolean",
5858
"description": "EmbeddedSpec indicates whether to embed the swagger spec in the generated code"
59+
},
60+
"server-urls": {
61+
"type": "boolean",
62+
"description": "Generate types for the `Server` definitions' URLs, instead of needing to provide your own values"
5963
}
6064
}
6165
},
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
openapi: "3.0.0"
2+
info:
3+
version: 1.0.0
4+
title: Server URLs can be optionally generated
5+
servers:
6+
# adapted from https://spec.openapis.org/oas/v3.0.3#server-object
7+
- url: https://development.gigantic-server.com/v1
8+
description: Development server
9+
- url: https://staging.gigantic-server.com/v1
10+
description: Staging server
11+
- url: https://api.gigantic-server.com/v1
12+
description: Production server
13+
# adapted from https://spec.openapis.org/oas/v3.0.3#server-object
14+
- url: https://{username}.gigantic-server.com:{port}/{basePath}
15+
description: The production API server
16+
variables:
17+
username:
18+
# note! no enum here means it is an open value
19+
default: demo
20+
description: this value is assigned by the service provider, in this example `gigantic-server.com`
21+
port:
22+
enum:
23+
- '8443'
24+
- '443'
25+
default: '8443'
26+
basePath:
27+
# open meaning there is the opportunity to use special base paths as assigned by the provider, default is `v2`
28+
default: v2
29+
# an example of a type that's defined, but doesn't have a default
30+
noDefault: {}
31+
# # TODO this conflict will cause broken generated code https://github.com/oapi-codegen/oapi-codegen/issues/2003
32+
# conflicting:
33+
# enum:
34+
# - 'default'
35+
# - '443'
36+
# default: 'default'
37+
# clash with the previous definition of `Development server` to trigger a new name
38+
- url: http://localhost:80
39+
description: Development server
40+
# clash with the previous definition of `Development server` to trigger a new name (again)
41+
- url: http://localhost:80
42+
description: Development server
43+
# make sure that the lowercase `description` gets converted to an uppercase
44+
- url: http://localhost:80
45+
description: some lowercase name
46+
# there may be URLs on their own, without a `description`
47+
- url: http://localhost:443
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# yaml-language-server: $schema=../../../configuration-schema.json
2+
package: serverurls
3+
output: gen.go
4+
generate:
5+
server-urls: true
6+
output-options:
7+
# to make sure that all types are generated, even if they're unreferenced
8+
skip-prune: true

examples/generate/serverurls/gen.go

Lines changed: 74 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package serverurls
2+
3+
import (
4+
"net/url"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestServerUrlTheProductionAPIServer(t *testing.T) {
12+
t.Run("when no values are provided, it does not error", func(t *testing.T) {
13+
serverUrl, err := NewServerUrlTheProductionAPIServer("", "", "", "")
14+
require.NoError(t, err)
15+
16+
assert.Equal(t, "https://.gigantic-server.com:/", serverUrl)
17+
18+
// NOTE that ideally this should fail as it doesn't /seem/ to provide a valid URL, but it does seem to be valid
19+
_, err = url.Parse(serverUrl)
20+
require.NoError(t, err)
21+
})
22+
23+
// TODO:when we validate enums, this will need more testing https://github.com/oapi-codegen/oapi-codegen/issues/2006
24+
t.Run("when values that are not part of the enum are provided, it does not error", func(t *testing.T) {
25+
invalidPort := ServerUrlTheProductionAPIServerPortVariable("12345")
26+
serverUrl, err := NewServerUrlTheProductionAPIServer(
27+
ServerUrlTheProductionAPIServerBasePathVariableDefault,
28+
ServerUrlTheProductionAPIServerNoDefaultVariable(""),
29+
invalidPort,
30+
ServerUrlTheProductionAPIServerUsernameVariableDefault,
31+
)
32+
require.NoError(t, err)
33+
34+
assert.Equal(t, "https://demo.gigantic-server.com:12345/v2", serverUrl)
35+
})
36+
37+
t.Run("when default values are provided, it does not error", func(t *testing.T) {
38+
serverUrl, err := NewServerUrlTheProductionAPIServer(
39+
ServerUrlTheProductionAPIServerBasePathVariableDefault,
40+
ServerUrlTheProductionAPIServerNoDefaultVariable(""),
41+
ServerUrlTheProductionAPIServerPortVariableDefault,
42+
ServerUrlTheProductionAPIServerUsernameVariableDefault,
43+
)
44+
require.NoError(t, err)
45+
46+
assert.Equal(t, "https://demo.gigantic-server.com:8443/v2", serverUrl)
47+
})
48+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package serverurls
2+
3+
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config cfg.yaml api.yaml

pkg/codegen/codegen.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,14 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) {
202202
MergeImports(xGoTypeImports, imprts)
203203
}
204204

205+
var serverURLsDefinitions string
206+
if opts.Generate.ServerURLs {
207+
serverURLsDefinitions, err = GenerateServerURLs(t, spec)
208+
if err != nil {
209+
return "", fmt.Errorf("error generating Server URLs: %w", err)
210+
}
211+
}
212+
205213
var irisServerOut string
206214
if opts.Generate.IrisServer {
207215
irisServerOut, err = GenerateIrisServer(t, ops)
@@ -326,6 +334,11 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) {
326334
return "", fmt.Errorf("error writing constants: %w", err)
327335
}
328336

337+
_, err = w.WriteString(serverURLsDefinitions)
338+
if err != nil {
339+
return "", fmt.Errorf("error writing Server URLs: %w", err)
340+
}
341+
329342
_, err = w.WriteString(typeDefinitions)
330343
if err != nil {
331344
return "", fmt.Errorf("error writing type definitions: %w", err)

pkg/codegen/configuration.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ type GenerateOptions struct {
126126
Models bool `yaml:"models,omitempty"`
127127
// EmbeddedSpec indicates whether to embed the swagger spec in the generated code
128128
EmbeddedSpec bool `yaml:"embedded-spec,omitempty"`
129+
// ServerURLs generates types for the `Server` definitions' URLs, instead of needing to provide your own values
130+
ServerURLs bool `yaml:"server-urls,omitempty"`
129131
}
130132

131133
func (oo GenerateOptions) Validate() map[string]string {

pkg/codegen/server_urls.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package codegen
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"text/template"
7+
8+
"github.com/getkin/kin-openapi/openapi3"
9+
)
10+
11+
const serverURLPrefix = "ServerUrl"
12+
const serverURLSuffixIterations = 10
13+
14+
// 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`
15+
type ServerObjectDefinition struct {
16+
// GoName is the name of the variable for this Server URL
17+
GoName string
18+
19+
// OAPISchema is the underlying OpenAPI representation of the Server
20+
OAPISchema *openapi3.Server
21+
}
22+
23+
func GenerateServerURLs(t *template.Template, spec *openapi3.T) (string, error) {
24+
names := make(map[string]*openapi3.Server)
25+
26+
for _, server := range spec.Servers {
27+
suffix := server.Description
28+
if suffix == "" {
29+
suffix = nameNormalizer(server.URL)
30+
}
31+
name := serverURLPrefix + UppercaseFirstCharacter(suffix)
32+
name = nameNormalizer(name)
33+
34+
// if this is the only type with this name, store it
35+
if _, conflict := names[name]; !conflict {
36+
names[name] = server
37+
continue
38+
}
39+
40+
// otherwise, try appending a number to the name
41+
saved := false
42+
// NOTE that we start at 1 on purpose, as
43+
//
44+
// ... ServerURLDevelopmentServer
45+
// ... ServerURLDevelopmentServer1`
46+
//
47+
// reads better than:
48+
//
49+
// ... ServerURLDevelopmentServer
50+
// ... ServerURLDevelopmentServer0
51+
for i := 1; i < 1+serverURLSuffixIterations; i++ {
52+
suffixed := name + strconv.Itoa(i)
53+
// and then store it if there's no conflict
54+
if _, suffixConflict := names[suffixed]; !suffixConflict {
55+
names[suffixed] = server
56+
saved = true
57+
break
58+
}
59+
}
60+
61+
if saved {
62+
continue
63+
}
64+
65+
// otherwise, error
66+
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)
67+
}
68+
69+
keys := SortedMapKeys(names)
70+
servers := make([]ServerObjectDefinition, len(keys))
71+
i := 0
72+
for _, k := range keys {
73+
servers[i] = ServerObjectDefinition{
74+
GoName: k,
75+
OAPISchema: names[k],
76+
}
77+
i++
78+
}
79+
80+
return GenerateTemplates([]string{"server-urls.tmpl"}, t, servers)
81+
}

0 commit comments

Comments
 (0)