Skip to content

Commit

Permalink
Generate enums for server variables (#618)
Browse files Browse the repository at this point in the history
### Motivation

Refer to proposal
#629

<details>
<summary>PR description prior to raising proposal</summary>
### Motivation

Recently in a project I was using a spec which defined variables similar
to below
```yaml
servers:
  - url: https://{environment}.example.com/api/{version}
    variables:
      environment:
        default: prod
        enum:
          - prod
          - staging
          - dev
      version:
        default: v1
```

The generated code to create the default server URL was easy enough
being able to utilise the default parameters
```swift
let serverURL = try Servers.server1()
```

But when I wanted to use a different variable I noticed that the
parameter was generated as a string and it didn't expose the other
allowed values that were defined in the OpenAPI document. It generated
the following code:
```swift
/// Server URLs defined in the OpenAPI document.
internal enum Servers {
    ///
    /// - Parameters:
    ///   - environment:
    ///   - version:
    internal static func server1(
        environment: Swift.String = "prod",
        version: Swift.String = "v1"
    ) throws -> Foundation.URL {
        try Foundation.URL(
            validatingOpenAPIServerURL: "https://{environment}.example.com/api/{version}",
            variables: [
                .init(
                    name: "environment",
                    value: environment,
                    allowedValues: [
                        "prod",
                        "staging",
                        "dev"
                    ]
                ),
                .init(
                    name: "version",
                    value: version
                )
            ]
        )
    }
}
```

This meant usage needed to involve runtime checks whether the supplied
variable was valid and if the OpenAPI document were to ever remove an
option it could only be discovered at runtime.

```swift
let serverURL = try Servers.server1(environment: "stg") // might be a valid environment, might not
```

Looking into the OpenAPI spec for server templating and the
implementation of the extension
`URL.init(validatingOpenAPIServerURL:variables:)` I realised that the
variables could very easily be represented by an enum in the generated
code. By doing so it would also provide a compiler checked way to use a
non-default variable.

### Modifications

I have introduced a new set of types translator functions in the file
`translateServersVariables.swift` which can create the enum declarations
for the variables. If there are no variables defined then no declaration
is generated.

Each variable defined in the OpenAPI document is generated as an enum
with a case that represents each enum in the document. Each enum is also
generated with a static computed property with the name `default` which
returns the default value as required by the OpenAPI spec. These
individual variable enums are then namespaced according to the server
they are applicable for, for example `Server1`, allowing servers to have
identically named variables with different enum values. Finally each of
the server namespace enums are members of a final namespace,
`Variables`, which exists as a member of the pre-existing `Servers`
namespace. A truncated example:

```swift
enum Servers { // enum generated prior to this PR
  enum Variables {
    enum Server1 {
      enum VariableName1 {
        // ...
      }
      enum VariableName2 {
        // ...
      }
    }
  }
  static func server1(/* ... */) throws -> Foundation.URL { /* declaration prior to this PR */ }
}
```

To use the new translator functions the `translateServers` function has
been modified to call the `translateServersVariables` function and
insert the declarations as a member alongside the existing static
functions for each of the servers. The `translateServer(index:server:)`
function was also edited to make use of the generated variable enums,
and the code which generated the string array for `allowedValues` has
been removed; runtime validation should no longer be required, as the
`rawValue` of a variable enum is the value defined in the OpenAPI
document.

### Result

The following spec
```yaml
servers:
  - url: https://{environment}.example.com/api/
    variables:
      environment:
        default: prod
        enum:
          - prod
          - staging
          - dev
```
Would currently generate to the output
```swift
/// Server URLs defined in the OpenAPI document.
internal enum Servers {
    ///
    /// - Parameters:
    ///   - environment:
    internal static func server1(environment: Swift.String = "prod") throws -> Foundation.URL {
        try Foundation.URL(
            validatingOpenAPIServerURL: "https://{environment}.example.com/api/",
            variables: [
                .init(
                    name: "environment",
                    value: environment,
                    allowedValues: [
                        "prod",
                        "staging",
                        "dev"
                    ]
                )
            ]
        )
    }
}
```
But with this PR it would generate to be
```swift
/// Server URLs defined in the OpenAPI document.
internal enum Servers {
    /// Server URL variables defined in the OpenAPI document.
    internal enum Variables {
        /// The variables for Server1 defined in the OpenAPI document.
        internal enum Server1 {
            /// The "environment" variable defined in the OpenAPI document.
            ///
            /// The default value is "prod".
            internal enum Environment: Swift.String {
                case prod
                case staging
                case dev
                /// The default variable.
                internal static var `default`: Environment {
                    return Environment.prod
                }
            }
        }
    }
    ///
    /// - Parameters:
    ///   - environment:
    internal static func server1(environment: Variables.Server1.Environment = Variables.Server1.Environment.default) throws -> Foundation.URL {
        try Foundation.URL(
            validatingOpenAPIServerURL: "https://{environment}.example.com/api/",
            variables: [
                .init(
                    name: "environment",
                    value: environment.rawValue
                )
            ]
        )
    }
}
```

Now when it comes to usage
```swift
let url = try Servers.server1() // ✅ works

let url = try Servers.server1(environment: .default)  // ✅ works

let url = try Servers.server1(environment: .staging)  // ✅ works

let url = try Servers.server1(environment: .stg)  // ❌ compiler error, stg not defined on the enum

// some time later staging gets removed from OpenAPI document
let url = try Servers.server1(environment: . staging)  // ❌ compiler error, staging not defined on the enum
```

If the document does not define enum values for the variable, an enum is
still generated with a single member (the default required by the spec).

```yaml
servers:
  - url: https://example.com/api/{version}
    variables:
      version:
        default: v1
```

Before this PR:
```swift
/// Server URLs defined in the OpenAPI document.
internal enum Servers {
    ///
    /// - Parameters:
    ///   - version:
    internal static func server1(version: Swift.String = "v1") throws -> Foundation.URL {
        try Foundation.URL(
            validatingOpenAPIServerURL: "https://example.com/api/{version}",
            variables: [
                .init(
                    name: "version",
                    value: version
                )
            ]
        )
    }
}
```

With this PR:
```swift
/// Server URLs defined in the OpenAPI document.
internal enum Servers {
    /// Server URL variables defined in the OpenAPI document.
    internal enum Variables {
        /// The variables for Server1 defined in the OpenAPI document.
        internal enum Server1 {
            /// The "version" variable defined in the OpenAPI document.
            ///
            /// The default value is "v1".
            internal enum Version: Swift.String {
                case v1
                /// The default variable.
                internal static var `default`: Version {
                    return Version.v1
                }
            }
        }
    }
    ///
    /// - Parameters:
    ///   - version:
    internal static func server1(version: Variables.Server1.Version = Variables.Server1.Version.default) throws -> Foundation.URL {
        try Foundation.URL(
            validatingOpenAPIServerURL: "https://example.com/api/{version}",
            variables: [
                .init(
                    name: "version",
                    value: version.rawValue
                )
            ]
        )
    }
}
```

</details>

### Result

Refer to
#618 (comment)

### Test Plan

I have updated the petstore unit tests to reflect the changes made in
this PR, see diff.

---------

Co-authored-by: Honza Dvorsky <[email protected]>
  • Loading branch information
theoriginalbit and czechboy0 authored Oct 11, 2024
1 parent a8e142e commit ef6d07f
Show file tree
Hide file tree
Showing 26 changed files with 773 additions and 73 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1628,9 +1628,15 @@ extension KeywordKind {
}

extension Declaration {
/// Returns a new deprecated variant of the declaration if the provided `description` is not `nil`.
func deprecate(if description: DeprecationDescription?) -> Self {
if let description { return .deprecated(description, self) }
return self
}

/// Returns a new deprecated variant of the declaration if `shouldDeprecate` is true.
func deprecate(if shouldDeprecate: Bool) -> Self {
if shouldDeprecate { return .deprecated(.init(), self) }
func deprecate(if shouldDeprecate: Bool, description: @autoclosure () -> DeprecationDescription = .init()) -> Self {
if shouldDeprecate { return .deprecated(description(), self) }
return self
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ enum Constants {

/// The prefix of each generated method name.
static let propertyPrefix: String = "server"
/// The name of each generated static function.
static let urlStaticFunc: String = "url"

/// The prefix of the namespace that contains server specific variables.
static let serverNamespacePrefix: String = "Server"

/// Constants related to the OpenAPI server variable object.
enum Variable {

/// The types that the protocol conforms to.
static let conformances: [String] = [TypeName.string.fullyQualifiedSwiftName, "Sendable"]
}
}

/// Constants related to the configuration type, which is used by both
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,47 +14,47 @@
import OpenAPIKit

extension TypesFileTranslator {

/// Returns a declaration of a server URL static method defined in
/// the OpenAPI document.
/// Returns a declaration of a server URL static function defined in
/// the OpenAPI document using the supplied name identifier and
/// variable generators.
///
/// If the `deprecated` parameter is supplied the static function
/// will be generated with a name that matches the previous, now
/// deprecated API.
///
/// - Important: The variable generators provided should all
/// be ``RawStringTranslatedServerVariable`` to ensure
/// the generated function matches the previous implementation, this
/// is **not** asserted by this translate function.
///
/// If the `deprecated` parameter is `nil` then the function will
/// be generated with the identifier `url` and must be a member
/// of a namespace to avoid conflicts with other server URL static
/// functions.
///
/// - Parameters:
/// - index: The index of the server in the list of servers defined
/// in the OpenAPI document.
/// - server: The server URL information.
/// - deprecated: A deprecation `@available` annotation to attach
/// to this declaration, or `nil` if the declaration should not be deprecated.
/// - variables: The generators for variables the server has defined.
/// - Returns: A static method declaration, and a name for the variable to
/// declare the method under.
func translateServer(index: Int, server: OpenAPI.Server) -> Declaration {
let methodName = "\(Constants.ServerURL.propertyPrefix)\(index+1)"
let safeVariables = server.variables.map { (key, value) in
(originalKey: key, swiftSafeKey: context.asSwiftSafeName(key), value: value)
}
let parameters: [ParameterDescription] = safeVariables.map { (originalKey, swiftSafeKey, value) in
.init(label: swiftSafeKey, type: .init(TypeName.string), defaultValue: .literal(value.default))
}
let variableInitializers: [Expression] = safeVariables.map { (originalKey, swiftSafeKey, value) in
let allowedValuesArg: FunctionArgumentDescription?
if let allowedValues = value.enum {
allowedValuesArg = .init(
label: "allowedValues",
expression: .literal(.array(allowedValues.map { .literal($0) }))
)
} else {
allowedValuesArg = nil
}
return .dot("init")
.call(
[
.init(label: "name", expression: .literal(originalKey)),
.init(label: "value", expression: .identifierPattern(swiftSafeKey)),
] + (allowedValuesArg.flatMap { [$0] } ?? [])
)
}
let methodDecl = Declaration.commentable(
.functionComment(abstract: server.description, parameters: safeVariables.map { ($1, $2.description) }),
private func translateServerStaticFunction(
index: Int,
server: OpenAPI.Server,
deprecated: DeprecationDescription?,
variableGenerators variables: [any ServerVariableGenerator]
) -> Declaration {
let name =
deprecated == nil ? Constants.ServerURL.urlStaticFunc : "\(Constants.ServerURL.propertyPrefix)\(index + 1)"
return .commentable(
.functionComment(abstract: server.description, parameters: variables.map(\.functionComment)),
.function(
accessModifier: config.access,
kind: .function(name: methodName, isStatic: true),
parameters: parameters,
kind: .function(name: name, isStatic: true),
parameters: variables.map(\.parameter),
keywords: [.throws],
returnType: .identifierType(TypeName.url),
body: [
Expand All @@ -65,14 +65,78 @@ extension TypesFileTranslator {
.init(
label: "validatingOpenAPIServerURL",
expression: .literal(.string(server.urlTemplate.absoluteString))
), .init(label: "variables", expression: .literal(.array(variableInitializers))),
),
.init(
label: "variables",
expression: .literal(.array(variables.map(\.initializer)))
),
])
)
)
]
)
.deprecate(if: deprecated)
)
}

/// Returns a declaration of a server URL static function defined in
/// the OpenAPI document. The function is marked as deprecated
/// with a message informing the adopter to use the new type-safe
/// API.
/// - Parameters:
/// - index: The index of the server in the list of servers defined
/// in the OpenAPI document.
/// - server: The server URL information.
/// - pathToReplacementSymbol: The Swift path of the symbol
/// which has resulted in the deprecation of this symbol.
/// - Returns: A static function declaration.
func translateServerAsDeprecated(index: Int, server: OpenAPI.Server, renamedTo pathToReplacementSymbol: String)
-> Declaration
{
let serverVariables = translateServerVariables(index: index, server: server, generateAsEnum: false)
return translateServerStaticFunction(
index: index,
server: server,
deprecated: DeprecationDescription(renamed: pathToReplacementSymbol),
variableGenerators: serverVariables
)
}

/// Returns a namespace (enum) declaration for a server defined in
/// the OpenAPI document. Within the namespace are enums to
/// represent any variables that also have enum values defined in the
/// OpenAPI document, and a single static function named 'url' which
/// at runtime returns the resolved server URL.
///
/// The server's namespace is named to identify the human-friendly
/// index of the enum (e.g. Server1) and is present to ensure each
/// server definition's variables do not conflict with one another.
/// - Parameters:
/// - index: The index of the server in the list of servers defined
/// in the OpenAPI document.
/// - server: The server URL information.
/// - Returns: A static function declaration.
func translateServer(index: Int, server: OpenAPI.Server) -> (pathToStaticFunction: String, decl: Declaration) {
let serverVariables = translateServerVariables(index: index, server: server, generateAsEnum: true)
let methodDecl = translateServerStaticFunction(
index: index,
server: server,
deprecated: nil,
variableGenerators: serverVariables
)
let namespaceName = "\(Constants.ServerURL.serverNamespacePrefix)\(index + 1)"
let typeName = TypeName(swiftKeyPath: [
Constants.ServerURL.namespace, namespaceName, Constants.ServerURL.urlStaticFunc,
])
let decl = Declaration.commentable(
server.description.map(Comment.doc(_:)),
.enum(
accessModifier: config.access,
name: namespaceName,
members: serverVariables.compactMap(\.declaration) + CollectionOfOne(methodDecl)
)
)
return methodDecl
return (pathToStaticFunction: typeName.fullyQualifiedSwiftName, decl: decl)
}

/// Returns a declaration of a namespace (enum) called "Servers" that
Expand All @@ -81,7 +145,18 @@ extension TypesFileTranslator {
/// - Parameter servers: The servers to include in the extension.
/// - Returns: A declaration of an enum namespace of the server URLs type.
func translateServers(_ servers: [OpenAPI.Server]) -> Declaration {
let serverDecls = servers.enumerated().map(translateServer)
var serverDecls: [Declaration] = []
for (index, server) in servers.enumerated() {
let translatedServer = translateServer(index: index, server: server)
serverDecls.append(contentsOf: [
translatedServer.decl,
translateServerAsDeprecated(
index: index,
server: server,
renamedTo: translatedServer.pathToStaticFunction
),
])
}
return .commentable(
.doc("Server URLs defined in the OpenAPI document."),
.enum(accessModifier: config.access, name: Constants.ServerURL.namespace, members: serverDecls)
Expand Down
Loading

0 comments on commit ef6d07f

Please sign in to comment.