From dffd9d100f1137803ac43d13714861b00f0acef7 Mon Sep 17 00:00:00 2001 From: Joshua Asbury <1377564+theoriginalbit@users.noreply.github.com> Date: Sat, 12 Oct 2024 05:30:44 +1100 Subject: [PATCH] [Proposal] SOAR-0012 Generate enums for server variables (#629) ### Motivation As requested by @czechboy0 in #618 I have created this proposal for community feedback. ### Modifications Added the proposal. Also fixed a typo in the document for the proposal process. ### Result N/A ### Test Plan N/A --------- Co-authored-by: Honza Dvorsky --- .../Documentation.docc/Proposals/Proposals.md | 3 +- .../Documentation.docc/Proposals/SOAR-0012.md | 428 ++++++++++++++++++ 2 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0012.md diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md index 752cc0a6..43b79807 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md @@ -4,7 +4,7 @@ Collaborate on API changes to Swift OpenAPI Generator by writing a proposal. ## Overview -For non-trivial changes that affect the public API, the Swift OpenAPI Generator project adopts a ligthweight version of the [Swift Evolution](https://github.com/apple/swift-evolution/blob/main/process.md) process. +For non-trivial changes that affect the public API, the Swift OpenAPI Generator project adopts a lightweight version of the [Swift Evolution](https://github.com/apple/swift-evolution/blob/main/process.md) process. Writing a proposal first helps discuss multiple possible solutions early, apply useful feedback from other contributors, and avoid reimplementing the same feature multiple times. @@ -52,3 +52,4 @@ If you have any questions, tag [Honza Dvorsky](https://github.com/czechboy0) or - - - +- diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0012.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0012.md new file mode 100644 index 00000000..11b1c4cb --- /dev/null +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0012.md @@ -0,0 +1,428 @@ +# SOAR-0012: Generate enums for server variables + +Introduce generator logic to generate Swift enums for server variables that define the 'enum' field. + +## Overview + +- Proposal: SOAR-0012 +- Author(s): [Joshua Asbury](https://github.com/theoriginalbit) +- Status: **Implemented (1.4.0)** +- Issue: [apple/swift-openapi-generator#628](https://github.com/apple/swift-openapi-generator/issues/628) +- Implementation: + - [apple/swift-openapi-generator#618](https://github.com/apple/swift-openapi-generator/pull/618) +- Affected components: + - generator +- Related links: + - [Server variable object](https://spec.openapis.org/oas/latest.html#server-variable-object) +- Versions: + - v1.0 (2024-09-19): Initial version + - v1.1 (2024-10-01): + - Replace the the proposed solution to a purely additive API so it is no longer a breaking change requiring a feature flag + - Moved previous proposed solution to alternatives considered section titled "Replace generation of `serverN` static functions, behind feature flag" + - Moved generation of static computed-property `default` on variable enums to future direction + +### Introduction + +Add generator logic to generate Swift enums for server variables that define the 'enum' field and use Swift String for server variables that only define the 'default' field. + +### Motivation + +The OpenAPI specification for server URL templating defines that fields can define an 'enum' field if substitution options should be restricted to a limited set. + +> | Field Name | Type | Description | +> | --- | --- | --- | +> | enum | [string] | An enumeration of string values to be used if the substitution options are from a limited set. The array MUST NOT be empty. | +> | default | string | REQUIRED. The default value to use for substitution, which SHALL be sent if an alternate value is not supplied. Note this behavior is different the Schema Object’s treatment of default values, because in those cases parameter values are optional. If the enum is defined, the value MUST exist the enum’s values. | +> | description | string | An optional description for the server variable. [CommonMark] syntax MAY be used for rich text representation. | +> +> — source: https://spec.openapis.org/oas/latest.html#server-variable-object + +The current implementation of the generator component offer the enum field values via strings that are embedded within the static function implementation and not exposed to the adopter. Relying on the runtime extension `URL.init(validatingOpenAPIServerURL:variables:)` to verify the string provided matches the allowed values. + +Consider the following example +```yaml +servers: + - url: https://{environment}.example.com/api/{version} + description: Example service deployment. + variables: + environment: + description: Server environment. + default: prod + enum: + - prod + - staging + - dev + version: + default: v1 +``` + +The currently generated code: +```swift +/// Server URLs defined in the OpenAPI document. +internal enum Servers { + /// Server environment. + /// + /// - 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 means the adopter needs to rely on the runtime checks as to whether their supplied string was valid. Additionally 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 +``` + +### Proposed solution + +Server variables that define enum values can instead be generated as Swift enums. Providing important information (including code completion) about allowed values to adopters, and providing compile-time guarantees that a valid variable has been supplied. + +Using the same configuration example, from the motivation section above, the newly generated code would be: +```swift +/// Server URLs defined in the OpenAPI document. +internal enum Servers { + /// Example service deployment. + internal enum Server1 { + /// Server environment. + /// + /// The "environment" variable defined in the OpenAPI document. The default value is ``prod``. + internal enum Environment: Swift.String { + case prod + case staging + case dev + } + /// + /// - Parameters: + /// - environment: Server environment. + /// - version: + internal static func url( + environment: Environment = Environment.prod, + version: Swift.String = "v1" + ) throws -> Foundation.URL { + try Foundation.URL( + validatingOpenAPIServerURL: "https://{environment}.example.com/api/{version}", + variables: [ + .init( + name: "environment", + value: environment.rawValue + ), + .init( + name: "version", + value: version + ) + ] + ) + } + } + /// Example service deployment. + /// + /// - Parameters: + /// - environment: Server environment. + /// - version: + @available(*, deprecated, message: "Migrate to the new type-safe API for server URLs.") + 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 leaves the existing implementation untouched, except for the addition of a deprecation message, and introduces a new type-safe structure that allows the compiler to validate the provided arguments. + +```swift +let url = try Servers.Server1.url() // ✅ compiles + +let url = try Servers.Server1.url(environment: .default) // ✅ compiles + +let url = try Servers.Server1.url(environment: .staging) // ✅ compiles + +let url = try Servers.Server1.url(environment: .stg) // ❌ compiler error, 'stg' not defined on the enum +``` + +Later if the OpenAPI document removes an enum value that was previously allowed, the compiler will be able to alert the adopter. +```swift +// some time later "staging" gets removed from OpenAPI document +let url = try Servers.Server1.url(environment: . staging) // ❌ compiler error, 'staging' not defined on the enum +``` + +#### Default only variables + +As seen in the generated code example, variables that do not define an 'enum' field will still remain a string (see the 'version' variable). + +### Detailed design + +Implementation: https://github.com/apple/swift-openapi-generator/pull/618 + +The implementation of `translateServers(_:)` is modified to generate the relevant namespaces (enums) for each server, deprecate the existing generated functions, and generate a new more type-safe function. A new file `translateServersVariables` has been created to contain implementations of the two generator kinds; enum and string. + +The server namespace contains a newly named `url` static function which serves the same purpose as the `serverN` static functions generated as members of the `Servers` namespace; it has been named `url` to both be more expressive and because the containing namespace already provides the server context. + +The server namespace also lends the purpose of containing the variable enums, should they be required, since servers may declare variables that are named the same but contain different enum values. e.g. +```yaml +servers: + - url: https://{env}.example.com + variables: + environment: + default: prod + enum: + - prod + - staging + - url: https://{env}.example2.com + variables: + environment: + default: prod + enum: + - prod + - dev +``` +The above would generate the following (simplified for clarity) output +```swift +enum Servers { + enum Server1 { + enum Environment: String { + // ... + } + static func url(/* ... */) throws -> Foundation.URL { /* omitted for brevity */ } + } + enum Server2 { + enum Environment: String { + // ... + } + static func url(/* ... */) throws -> Foundation.URL { /* omitted for brevity */ } + } + + static func server1(/* ... */) throws -> Foundation.URL { /* existing implementation omitted for brevity */ } + static func server2(/* ... */) throws -> Foundation.URL { /* existing implementation omitted for brevity */ } +} +``` + +Server variables that have names or enum values that are not safe to be used as a Swift identifier will be converted. E.g. +```swift +enum Servers { + enum Server1 { + enum _Protocol: String { + case https + case https + } + enum Port: String { + case _443 = "443" + case _8443 = "8443" + } + static func url(/* ... */) throws -> Foundation.URL { /* omitted for brevity */ } + } +} +``` + +#### Deeper into the implementation + +To handle the branching logic of whether a variable will be generated as a string or an enum a new protocol, `TranslatedServerVariable`, defines the common behaviours that may need to occur within each branch. This includes: +- any required declarations +- the parameters for the server's static function +- the expression for the variable initializer in the static function's body +- the parameter description for the static function's documentation + +There are two concrete implementations of this protocol to handle the two branching paths in logic + +##### `RawStringTranslatedServerVariable` + +This concrete implementation will not provide a declaration for generated enum. + +It will define the parameter using `Swift.String` and a default value that is a String representation of the OpenAPI document defined default field. + +The generated initializer expression will match the existing implementation of a variable that does not define an enum field. + +Note: While the feature flag for this proposal is disabled this type is also used to generate the initializer expression to include the enum field as the allowed values parameter. + +##### `GeneratedEnumTranslatedServerVariable` + +This concrete implementation will provide an enum declaration which represents the variable's enum field and a static computed property to access the default. + +The parameter will reference a fully-qualified path to the generated enum declaration and have a default value of the fully qualified path to the static property accessor. + +The initializer expression will never need to provide the allowed values parameter and only needs to provide the `rawValue` of the enum. + +### API stability + +This proposal creates new generated types and modifies the existing generated static functions to include a deprecation, therefore is a non-breaking change for adopters. + +#### Other components + +No API changes are required to other components, though once this proposal is adopted the runtime component _could_ remove the runtime validation of allowed values since the generated code guarantees the `rawValue` is in the document. + +### Future directions + +#### Variable enums could have a static computed-property convenience, called `default`, generated + +Each server variable enum could generate a static computed-property with the name `default` which returns the case as defined by the OpenAPI document. e.g. +```swift +enum Servers { + enum Variables { + enum Server1 { + enum Environment: Swift.String { + case prod + case staging + case dev + static var `default`: Environment { + return Environment.prod + } + } + } + } +``` +This would allow the server's static function to use `default` as the default parameter instead of using a specific case. + +### Alternatives considered + +#### Generate all variables as Swift enums + +A previous implementation had generated all variables as a swift enum, even if the 'enum' field was not defined in the document. An example +```yaml +servers: + - url: https://example.com/api/{version} + variables: + version: + default: v1 +``` +Would have been generated as +```swift +/// Server URLs defined in the OpenAPI document. +internal enum Servers { + 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 + ) + ] + ) + } +} +``` +This approach was reconsidered due to the wording in the OpenAPI specification of both the 'enum' and 'default' fields. + +> An enumeration of string values to be used if the substitution options are from a limited set. The array MUST NOT be empty. | +> +> The default value to use for substitution, which SHALL be sent if an alternate value is not supplied. +> +> — source: https://spec.openapis.org/oas/latest.html#server-variable-object + +This indicates that by providing enum values the options are restricted, whereas a default value is provided when no other value is supplied. + +#### Replace generation of `serverN` static functions, behind feature flag + +This approach was considered to be added behind a feature flag as it would introduce breaking changes for adopters that didn't use default values; it would completely rewrite the static functions to accept enum variables as Swift enums. + +An example of the output, using the same configuration example from the motivation section above, this approach would generate the following code: +```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 { + /// Server environment. + /// + /// 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 + } + } + } + } + /// Example service deployment. + /// + /// - Parameters: + /// - environment: Server environment. + /// - version: + internal static func server1( + environment: Variables.Server1.Environment = Variables.Server1.Environment.default, + version: Swift.String = "v1" + ) throws -> Foundation.URL { + try Foundation.URL( + validatingOpenAPIServerURL: "https://example.com/api", + variables: [ + .init( + name: "environment", + value: environment.rawValue + ), + .init( + name: "version", + value: version + ) + ] + ) + } +} +``` + +The variables were scoped within a `Variables` namespace for clarity, and each server had its own namespace to avoid collisions of names between different servers. + +Ultimately this approach was decided against due to lack of discoverability since it would have to be feature flagged.