diff --git a/Examples/replace-types-example/.gitignore b/Examples/replace-types-example/.gitignore new file mode 100644 index 00000000..f6f5465e --- /dev/null +++ b/Examples/replace-types-example/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.vscode +/Package.resolved +.ci/ +.docc-build/ diff --git a/Examples/replace-types-example/Package.swift b/Examples/replace-types-example/Package.swift new file mode 100644 index 00000000..4f0f4d41 --- /dev/null +++ b/Examples/replace-types-example/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version:5.9 +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2024 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import PackageDescription + +let package = Package( + name: "replace-types-example", + platforms: [.macOS(.v14)], + products: [ + .library(name: "Types", targets: ["Types"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.7.0"), + ], + targets: [ + .target( + name: "Types", + dependencies: [ + "ExternalLibrary", + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime")] + ), + .target( + name: "ExternalLibrary" + ), + ] +) diff --git a/Examples/replace-types-example/README.md b/Examples/replace-types-example/README.md new file mode 100644 index 00000000..974e6d52 --- /dev/null +++ b/Examples/replace-types-example/README.md @@ -0,0 +1,40 @@ +# Replacing types + +An example project using [Swift OpenAPI Generator](https://github.com/apple/swift-openapi-generator). + +> **Disclaimer:** This example is deliberately simplified and is intended for illustrative purposes only. + +## Overview + +This example shows how you can structure a Swift package to share the types +from an OpenAPI document between a client and server module by having a common +target that runs the generator in `types` mode only. + +This allows you to write extensions or other helper functions that use these +types and use them in both the client and server code. + +## Usage + +Build and run the server using: + +```console +% swift run hello-world-server +Build complete! +... +info HummingBird : [HummingbirdCore] Server started and listening on 127.0.0.1:8080 +``` + +Then, in another terminal window, run the client: + +```console +% swift run hello-world-client +Build complete! ++––––––––––––––––––+ +|+––––––––––––––––+| +||Hello, Stranger!|| +|+––––––––––––––––+| ++––––––––––––––––––+ +``` + +Note how the message is boxed twice: once by the server and once by the client, +both using an extension on a shared type, defined in the `Types` module. diff --git a/Examples/replace-types-example/Sources/ExternalLibrary/ExternalObject.swift b/Examples/replace-types-example/Sources/ExternalLibrary/ExternalObject.swift new file mode 100644 index 00000000..5082fd55 --- /dev/null +++ b/Examples/replace-types-example/Sources/ExternalLibrary/ExternalObject.swift @@ -0,0 +1,4 @@ +public struct ExternalObject: Codable, Hashable, Sendable { + public let foo: String + public let bar: String +} diff --git a/Examples/replace-types-example/Sources/ExternalLibrary/PrimeNumber.swift b/Examples/replace-types-example/Sources/ExternalLibrary/PrimeNumber.swift new file mode 100644 index 00000000..3d7dddd4 --- /dev/null +++ b/Examples/replace-types-example/Sources/ExternalLibrary/PrimeNumber.swift @@ -0,0 +1,35 @@ +public struct PrimeNumber: Codable, Hashable, RawRepresentable, Sendable { + public let rawValue: Int + public init?(rawValue: Int) { + if !rawValue.isPrime { return nil } + self.rawValue = rawValue + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let number = try container.decode(Int.self) + guard let value = PrimeNumber(rawValue: number) else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "The number is not prime.") + } + self = value + } + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.rawValue) + } + +} + +extension Int { + fileprivate var isPrime: Bool { + if self <= 1 { return false } + if self <= 3 { return true } + + var i = 2 + while i * i <= self { + if self % i == 0 { return false } + i += 1 + } + return true + } +} diff --git a/Examples/replace-types-example/Sources/Types/Generated/Types.swift b/Examples/replace-types-example/Sources/Types/Generated/Types.swift new file mode 100644 index 00000000..56d45551 --- /dev/null +++ b/Examples/replace-types-example/Sources/Types/Generated/Types.swift @@ -0,0 +1,277 @@ +// Generated by swift-openapi-generator, do not modify. +@_spi(Generated) import OpenAPIRuntime +#if os(Linux) +@preconcurrency import struct Foundation.URL +@preconcurrency import struct Foundation.Data +@preconcurrency import struct Foundation.Date +#else +import struct Foundation.URL +import struct Foundation.Data +import struct Foundation.Date +#endif +import Foundation +import ExternalLibrary +/// A type that performs HTTP operations defined by the OpenAPI document. +package protocol APIProtocol: Sendable { + /// - Remark: HTTP `GET /user`. + /// - Remark: Generated from `#/paths//user/get(getUser)`. + func getUser(_ input: Operations.GetUser.Input) async throws -> Operations.GetUser.Output +} + +/// Convenience overloads for operation inputs. +extension APIProtocol { + /// - Remark: HTTP `GET /user`. + /// - Remark: Generated from `#/paths//user/get(getUser)`. + package func getUser( + query: Operations.GetUser.Input.Query = .init(), + headers: Operations.GetUser.Input.Headers = .init() + ) async throws -> Operations.GetUser.Output { + try await getUser(Operations.GetUser.Input( + query: query, + headers: headers + )) + } +} + +/// Server URLs defined in the OpenAPI document. +package enum Servers { + /// Example service deployment. + package enum Server1 { + /// Example service deployment. + package static func url() throws -> Foundation.URL { + try Foundation.URL( + validatingOpenAPIServerURL: "https://example.com/api", + variables: [] + ) + } + } + /// Example service deployment. + @available(*, deprecated, renamed: "Servers.Server1.url") + package static func server1() throws -> Foundation.URL { + try Foundation.URL( + validatingOpenAPIServerURL: "https://example.com/api", + variables: [] + ) + } +} + +/// Types generated from the components section of the OpenAPI document. +package enum Components { + /// Types generated from the `#/components/schemas` section of the OpenAPI document. + package enum Schemas { + /// - Remark: Generated from `#/components/schemas/UUID`. + package typealias Uuid = Foundation.UUID + /// A value with the greeting contents. + /// + /// - Remark: Generated from `#/components/schemas/User`. + package struct User: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/User/id`. + package var id: Components.Schemas.Uuid? + /// - Remark: Generated from `#/components/schemas/User/favorite_prime_number`. + package var favoritePrimeNumber: ExternalLibrary.PrimeNumber? + /// - Remark: Generated from `#/components/schemas/User/foo`. + package var foo: ExternalLibrary.ExternalObject? + /// A container of undocumented properties. + package var additionalProperties: [String: ExternalLibrary.PrimeNumber] + /// Creates a new `User`. + /// + /// - Parameters: + /// - id: + /// - favoritePrimeNumber: + /// - foo: + /// - additionalProperties: A container of undocumented properties. + package init( + id: Components.Schemas.Uuid? = nil, + favoritePrimeNumber: ExternalLibrary.PrimeNumber? = nil, + foo: ExternalLibrary.ExternalObject? = nil, + additionalProperties: [String: ExternalLibrary.PrimeNumber] = .init() + ) { + self.id = id + self.favoritePrimeNumber = favoritePrimeNumber + self.foo = foo + self.additionalProperties = additionalProperties + } + package enum CodingKeys: String, CodingKey { + case id + case favoritePrimeNumber = "favorite_prime_number" + case foo + } + package init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent( + Components.Schemas.Uuid.self, + forKey: .id + ) + self.favoritePrimeNumber = try container.decodeIfPresent( + ExternalLibrary.PrimeNumber.self, + forKey: .favoritePrimeNumber + ) + self.foo = try container.decodeIfPresent( + ExternalLibrary.ExternalObject.self, + forKey: .foo + ) + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: [ + "id", + "favorite_prime_number", + "foo" + ]) + } + package func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent( + self.id, + forKey: .id + ) + try container.encodeIfPresent( + self.favoritePrimeNumber, + forKey: .favoritePrimeNumber + ) + try container.encodeIfPresent( + self.foo, + forKey: .foo + ) + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + } + /// Types generated from the `#/components/parameters` section of the OpenAPI document. + package enum Parameters {} + /// Types generated from the `#/components/requestBodies` section of the OpenAPI document. + package enum RequestBodies {} + /// Types generated from the `#/components/responses` section of the OpenAPI document. + package enum Responses {} + /// Types generated from the `#/components/headers` section of the OpenAPI document. + package enum Headers {} +} + +/// API operations, with input and output types, generated from `#/paths` in the OpenAPI document. +package enum Operations { + /// - Remark: HTTP `GET /user`. + /// - Remark: Generated from `#/paths//user/get(getUser)`. + package enum GetUser { + package static let id: Swift.String = "getUser" + package struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/user/GET/query`. + package struct Query: Sendable, Hashable { + /// The name of the user + /// + /// - Remark: Generated from `#/paths/user/GET/query/name`. + package var name: Swift.String? + /// Creates a new `Query`. + /// + /// - Parameters: + /// - name: The name of the user + package init(name: Swift.String? = nil) { + self.name = name + } + } + package var query: Operations.GetUser.Input.Query + /// - Remark: Generated from `#/paths/user/GET/header`. + package struct Headers: Sendable, Hashable { + package var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + package init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + package var headers: Operations.GetUser.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - query: + /// - headers: + package init( + query: Operations.GetUser.Input.Query = .init(), + headers: Operations.GetUser.Input.Headers = .init() + ) { + self.query = query + self.headers = headers + } + } + @frozen package enum Output: Sendable, Hashable { + package struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/user/GET/responses/200/content`. + @frozen package enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/user/GET/responses/200/content/application\/json`. + case json(Components.Schemas.User) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + package var json: Components.Schemas.User { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + package var body: Operations.GetUser.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + package init(body: Operations.GetUser.Output.Ok.Body) { + self.body = body + } + } + /// A success response with the user. + /// + /// - Remark: Generated from `#/paths//user/get(getUser)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.GetUser.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + package var ok: Operations.GetUser.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen package enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + package init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + package var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + package static var allCases: [Self] { + [ + .json + ] + } + } + } +} diff --git a/Examples/replace-types-example/Sources/Types/openapi-generator-config.yaml b/Examples/replace-types-example/Sources/Types/openapi-generator-config.yaml new file mode 100644 index 00000000..585b5e7d --- /dev/null +++ b/Examples/replace-types-example/Sources/Types/openapi-generator-config.yaml @@ -0,0 +1,7 @@ +generate: + - types +accessModifier: package +namingStrategy: idiomatic +additionalImports: + - Foundation + - ExternalLibrary diff --git a/Examples/replace-types-example/Sources/Types/openapi.yaml b/Examples/replace-types-example/Sources/Types/openapi.yaml new file mode 120000 index 00000000..1c2a243e --- /dev/null +++ b/Examples/replace-types-example/Sources/Types/openapi.yaml @@ -0,0 +1 @@ +../openapi.yaml \ No newline at end of file diff --git a/Examples/replace-types-example/Sources/openapi.yaml b/Examples/replace-types-example/Sources/openapi.yaml new file mode 100644 index 00000000..8d1e4d33 --- /dev/null +++ b/Examples/replace-types-example/Sources/openapi.yaml @@ -0,0 +1,55 @@ +openapi: '3.1.0' +info: + title: GreetingService + version: 1.0.0 +servers: + - url: https://example.com/api + description: Example service deployment. +paths: + /user: + get: + operationId: getUser + parameters: + - name: name + required: false + in: query + description: The name of the user + schema: + type: string + responses: + '200': + description: A success response with the user. + content: + application/json: + schema: + $ref: '#/components/schemas/User' +components: + schemas: + UUID: + type: string + format: uuid + x-swift-open-api-replace-type: Foundation.UUID + + User: + type: object + description: A value with the greeting contents. + properties: + id: + $ref: '#/components/schemas/UUID' + favorite_prime_number: + type: integer + x-swift-open-api-replace-type: ExternalLibrary.PrimeNumber + foo: + type: object + properties: + foo: + type: string + bar: + type: string + x-swift-open-api-replace-type: ExternalLibrary.ExternalObject + default: + foo: "foo" + bar: "bar" + additionalProperties: + type: integer + x-swift-open-api-replace-type: ExternalLibrary.PrimeNumber diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateSchema.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateSchema.swift index e92b5605..839db3f2 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateSchema.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateSchema.swift @@ -87,6 +87,16 @@ extension TypesFileTranslator { ) ) } + if let substituteType = schema.value.substituteType() { + let typealiasDecl = try translateSubstitutedType( + named: typeName, + userDescription: overrides.userDescription ?? schema.description, + to: substituteType.asUsage.withOptional( + overrides.isOptional ?? typeMatcher.isOptional(schema, components: components) + ) + ) + return [typealiasDecl] + } // If this type maps to a referenceable schema, define a typealias if let builtinType = try typeMatcher.tryMatchReferenceableType(for: schema, components: components) { diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateSubstitutedType.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateSubstitutedType.swift new file mode 100644 index 00000000..6973d4fa --- /dev/null +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateSubstitutedType.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import OpenAPIKit + +extension FileTranslator { + + /// Returns a declaration of a typealias. + /// - Parameters: + /// - typeName: The name of the type to give to the declared typealias. + /// - userDescription: A user-specified description from the OpenAPI document. + /// - existingTypeUsage: The existing type the alias points to. + /// - Throws: An error if there is an issue during translation. + /// - Returns: A declaration representing the translated typealias. + func translateSubstitutedType(named typeName: TypeName, userDescription: String?, to existingTypeUsage: TypeUsage) throws + -> Declaration + { + let typealiasDescription = TypealiasDescription( + accessModifier: config.access, + name: typeName.shortSwiftName, + existingType: .init(existingTypeUsage.withOptional(false)) + ) + let typealiasComment: Comment? = typeName.docCommentWithUserDescription(userDescription) + return .commentable(typealiasComment, .typealias(typealiasDescription)) + } +} diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift index d1fbedcf..66bc10d7 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift @@ -372,6 +372,8 @@ enum Constants { /// The substring used in method names for the multipart coding strategy. static let multipart: String = "Multipart" } + /// Constants related to the vendor extensions.. + enum VendorExtension { static let replaceType: String = "x-swift-open-api-replace-type" } /// Constants related to types used in many components. enum Global { diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift index 1c503ae7..716cef85 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// import OpenAPIKit +import Foundation /// A set of functions that match Swift types onto OpenAPI types. struct TypeMatcher { @@ -46,7 +47,11 @@ struct TypeMatcher { matchedArrayHandler: { elementType, nullableItems in nullableItems ? elementType.asOptional.asArray : elementType.asArray }, - genericArrayHandler: { TypeName.arrayContainer.asUsage } + genericArrayHandler: { TypeName.arrayContainer.asUsage }, + substitutedTypeHandler: { substitute in + // never built-in + nil + } ) } @@ -75,7 +80,10 @@ struct TypeMatcher { matchedArrayHandler: { elementType, nullableItems in nullableItems ? elementType.asOptional.asArray : elementType.asArray }, - genericArrayHandler: { TypeName.arrayContainer.asUsage } + genericArrayHandler: { TypeName.arrayContainer.asUsage }, + substitutedTypeHandler: { substitute in + substitute.asUsage + } )? .withOptional(isOptional(schema, components: components)) } @@ -98,7 +106,8 @@ struct TypeMatcher { return true }, matchedArrayHandler: { elementIsReferenceable, _ in elementIsReferenceable }, - genericArrayHandler: { true } + genericArrayHandler: { true }, + substitutedTypeHandler: { _ in true } ) ?? false } @@ -353,8 +362,10 @@ struct TypeMatcher { for schema: JSONSchema.Schema, test: (JSONSchema.Schema) throws -> R?, matchedArrayHandler: (R, _ nullableItems: Bool) -> R, - genericArrayHandler: () -> R + genericArrayHandler: () -> R, + substitutedTypeHandler: (TypeName) -> R? ) rethrows -> R? { + if let substitute = schema.substituteType() { return substitutedTypeHandler(substitute) } switch schema { case let .array(_, arrayContext): guard let items = arrayContext.items else { return genericArrayHandler() } @@ -363,7 +374,8 @@ struct TypeMatcher { for: items.value, test: test, matchedArrayHandler: matchedArrayHandler, - genericArrayHandler: genericArrayHandler + genericArrayHandler: genericArrayHandler, + substitutedTypeHandler: substitutedTypeHandler ) else { return nil } return matchedArrayHandler(itemsResult, items.nullable) @@ -371,3 +383,30 @@ struct TypeMatcher { } } } + +extension JSONSchema.Schema { + func substituteType() -> TypeName? { + let extensions: [String: AnyCodable] = + switch self { + case .null(let context): context.vendorExtensions + case .boolean(let context): context.vendorExtensions + case .number(let context, _): context.vendorExtensions + case .integer(let context, _): context.vendorExtensions + case .string(let context, _): context.vendorExtensions + case .object(let context, _): context.vendorExtensions + case .array(let context, _): context.vendorExtensions + case .all(of: _, core: let context): context.vendorExtensions + case .one(of: _, core: let context): context.vendorExtensions + case .any(of: _, core: let context): context.vendorExtensions + case .not: [:] + case .reference(_, let context): context.vendorExtensions + case .fragment(let context): context.vendorExtensions + } + guard let substituteTypeName = extensions[Constants.VendorExtension.replaceType]?.value as? String else { + return nil + } + assert(!substituteTypeName.isEmpty) + + return TypeName(swiftKeyPath: substituteTypeName.components(separatedBy: ".")) + } +} diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md index bdaff6a5..c8a91e34 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/Proposals.md @@ -55,3 +55,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-0014.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0014.md new file mode 100644 index 00000000..528a9aee --- /dev/null +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0014.md @@ -0,0 +1,132 @@ +# SOAR-0014: Support Type Substitutions + +Allow using user-defined types instead of generated ones, utilizing vendor-extensions + +## Overview + +- Proposal: SOAR-0014 +- Author(s): [simonbility](https://github.com/simonbility) +- Status: **Awaiting Review** +- Issue: [apple/swift-openapi-generator#375](https://github.com/apple/swift-openapi-generator/issues/375) +- Implementation: + - [apple/swift-openapi-generator#764](https://github.com/apple/swift-openapi-generator/pull/764) +- Affected components: + - generator + +### Introduction + +The goal of this proposal is to allow users to specify custom types for generated code using vendor extensions. This will enable users to use their own types instead of the default generated ones, allowing for greater flexibility. + +### Motivation + +This proposal would enable more flexibility in the generated code. +Some usecases include: +- Using custom types that are already defined in the user's codebase or even coming from a third party library, instead of generating new ones. +- workaround missing support for `format` for strings +- Implement custom validation/encoding/decoding logic that cannot be expressed using the OpenAPI spec + +This is intended as a "escape hatch" for use-cases that (currently) cannot be expressed. +Using this comes with the risk of user-provided types not being compliant with the original OpenAPI spec. + + +### Proposed solution + +The proposed solution is to allow the `x-swift-open-api-replace-type` vendor-extension to prevent the generation of types as usual, and instead use the specified type. +This should be supported anywhere within a OpenAPI document where a schema can be defined. (e.g. `components.schemas`, `properites`, `additionalProperties`, etc.) + +It can be used in "top-level" schemas, defined in `components.schemas` + +```diff + components: + schemas: + UUID: + type: string + format: uuid ++ x-swift-open-api-replace-type: Foundation.UUID +``` + +Will affect the generated code in the following way: +```diff + /// Types generated from the `#/components/schemas` section of the OpenAPI document. + enum Schemas { + /// - Remark: Generated from `#/components/schemas/UUID`. +- package typealias Uuid = Swift.String ++ package typealias Uuid = Foundation.UUID +``` + + +This will also work for properties defined inline + +```diff + components: + schemas: + UUID: + type: string + format: uuid + + User: + type: object + properties: + id: + type: string + name: + type: string ++ x-swift-open-api-replace-type: ExternalLibrary.ExternallyDefinedUser +``` + +Will affect the generated code in the following way: + +```diff +enum Schemas { + /// - Remark: Generated from `#/components/schemas/User`. +- package struct User: Codable, Hashable, Sendable { +- /// - Remark: Generated from `#/components/schemas/User/id`. +- package var id: Components.Schemas.Uuid? +- /// - Remark: Generated from `#/components/schemas/User/name`. +- package var name: Swift.String? +- /// Creates a new `User`. +- /// +- /// - Parameters: +- /// - id: +- /// - name: +- package init( +- id: Components.Schemas.Uuid? = nil, +- name: Swift.String? = nil +- ) { +- self.id = id +- self.name = name +- } +- package enum CodingKeys: String, CodingKey { +- case id +- case name +- } +- } ++ package typealias User = ExternalLibrary.ExternallyDefinedUser +} +``` + +### Detailed design + +The implementation modifies the Translator and the TypeAssignement logic to account for the presence of the vendor extension. + +### API stability + +While this proposal does affect the generated code, it requires the addition of a very specific vendor-extension. + +This is interpreted as a "strong enough" signal of the user to opt into this behaviour, to justify NOT introducing a feauture-flag or considering this a breaking change. + + +### Future directions + +None so far. + +### Alternatives considered +An alternative to relying on vendor-extension, was to allow specifying the types to be replaced via paths in the config file like this + +```yaml +... +replaceTypes: + #/components/schemas/User: Foundation.UUID +``` + +The advantage of this approach is that it could also be used without modifying the OpenAPI document. (which is not always possible/straightforward when using third party API-specs) diff --git a/Tests/OpenAPIGeneratorCoreTests/Translator/TypesTranslator/Test_typeSubstitutions.swift b/Tests/OpenAPIGeneratorCoreTests/Translator/TypesTranslator/Test_typeSubstitutions.swift new file mode 100644 index 00000000..2e0f2f63 --- /dev/null +++ b/Tests/OpenAPIGeneratorCoreTests/Translator/TypesTranslator/Test_typeSubstitutions.swift @@ -0,0 +1,216 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import XCTest +import OpenAPIKit +import Yams +@testable import _OpenAPIGeneratorCore + +class Test_typeSubstitutions: Test_Core { + + func testSchemaString() throws { + func _test( + schema schemaString: String, + expectedType: ExistingTypeDescription, + file: StaticString = #file, + line: UInt = #line + ) throws { + let typeName = TypeName(swiftKeyPath: ["Foo"]) + + let schema = try loadSchemaFromYAML(schemaString) + let collector = AccumulatingDiagnosticCollector() + let translator = makeTranslator(diagnostics: collector) + let translated = try translator.translateSchema(typeName: typeName, schema: schema, overrides: .none) + if translated.count != 1 { + XCTFail("Expected only a single translated schema, got: \(translated.count)", file: file, line: line) + return + } + XCTAssertTrue(translated.count == 1, "Should have one translated schema") + guard case let .typealias(typeAliasDescription) = translated.first?.strippingTopComment else { + XCTFail("Expected typealias description got", file: file, line: line) + return + } + XCTAssertEqual(typeAliasDescription.name, "Foo", file: file, line: line) + XCTAssertEqual(typeAliasDescription.existingType, expectedType, file: file, line: line) + } + try _test( + schema: #""" + type: string + x-swift-open-api-replace-type: MyLibrary.MyCustomType + """#, + expectedType: .member(["MyLibrary", "MyCustomType"]) + ) + try _test( + schema: """ + type: array + items: + type: integer + x-swift-open-api-replace-type: MyLibrary.MyCustomType + """, + expectedType: .member(["MyLibrary", "MyCustomType"]) + ) + try _test( + schema: """ + type: string + x-swift-open-api-replace-type: MyLibrary.MyCustomType + """, + expectedType: .member(["MyLibrary", "MyCustomType"]) + ) + try _test( + schema: """ + type: array + items: + type: integer + x-swift-open-api-replace-type: MyLibrary.MyCustomType + """, + expectedType: .array(.member(["MyLibrary", "MyCustomType"])) + ) + // TODO: Investigate if vendor-extensions are allowed in anyOf, allOf, oneOf + try _test( + schema: """ + anyOf: + - type: string + - type: integer + x-swift-open-api-replace-type: MyLibrary.MyCustomType + """, + expectedType: .member(["MyLibrary", "MyCustomType"]) + ) + try _test( + schema: """ + allOf: + - type: object + properties: + foo: + type: string + - type: object + properties: + bar: + type: string + x-swift-open-api-replace-type: MyLibrary.MyCustomType + """, + expectedType: .member(["MyLibrary", "MyCustomType"]) + ) + try _test( + schema: """ + oneOf: + - type: object + properties: + foo: + type: string + - type: object + properties: + bar: + type: string + x-swift-open-api-replace-type: MyLibrary.MyCustomType + """, + expectedType: .member(["MyLibrary", "MyCustomType"]) + ) + } + func testSimpleInlinePropertiesReplacements() throws { + func _testInlineProperty( + schema schemaString: String, + expectedType: ExistingTypeDescription, + file: StaticString = #file, + line: UInt = #line + ) throws { + let typeName = TypeName(swiftKeyPath: ["Foo"]) + + let propertySchema = try YAMLDecoder().decode(JSONSchema.self, from: schemaString).requiredSchemaObject() + let schema = JSONSchema.object(properties: ["property": propertySchema]) + let collector = AccumulatingDiagnosticCollector() + let translator = makeTranslator(diagnostics: collector) + let translated = try translator.translateSchema(typeName: typeName, schema: schema, overrides: .none) + if translated.count != 1 { + XCTFail("Expected only a single translated schema, got: \(translated.count)", file: file, line: line) + return + } + guard case let .struct(structDescription) = translated.first?.strippingTopComment else { + throw GenericError(message: "Expected struct") + } + let variables: [VariableDescription] = structDescription.members.compactMap { member in + guard case let .variable(variableDescription) = member.strippingTopComment else { return nil } + return variableDescription + } + if variables.count != 1 { + XCTFail("Expected only a single variable, got: \(variables.count)", file: file, line: line) + return + } + XCTAssertEqual(variables[0].type, expectedType, file: file, line: line) + } + try _testInlineProperty( + schema: """ + type: array + items: + type: integer + x-swift-open-api-replace-type: MyLibrary.MyCustomType + """, + expectedType: .member(["MyLibrary", "MyCustomType"]) + ) + try _testInlineProperty( + schema: """ + type: string + x-swift-open-api-replace-type: MyLibrary.MyCustomType + """, + expectedType: .member(["MyLibrary", "MyCustomType"]) + ) + try _testInlineProperty( + schema: """ + type: array + items: + type: integer + x-swift-open-api-replace-type: MyLibrary.MyCustomType + """, + expectedType: .array(.member(["MyLibrary", "MyCustomType"])) + ) + // TODO: Investigate if vendor-extensions are allowed in anyOf, allOf, oneOf + try _testInlineProperty( + schema: """ + anyOf: + - type: string + - type: integer + x-swift-open-api-replace-type: MyLibrary.MyCustomType + """, + expectedType: .member(["MyLibrary", "MyCustomType"]) + ) + try _testInlineProperty( + schema: """ + allOf: + - type: object + properties: + foo: + type: string + - type: object + properties: + bar: + type: string + x-swift-open-api-replace-type: MyLibrary.MyCustomType + """, + expectedType: .member(["MyLibrary", "MyCustomType"]) + ) + try _testInlineProperty( + schema: """ + oneOf: + - type: object + properties: + foo: + type: string + - type: object + properties: + bar: + type: string + x-swift-open-api-replace-type: MyLibrary.MyCustomType + """, + expectedType: .member(["MyLibrary", "MyCustomType"]) + ) + } +}