apple / swift-openapi-generator

Generate Swift client and server code from an OpenAPI document.
https://swiftpackageindex.com/apple/swift-openapi-generator/documentation
Apache License 2.0
1.45k stars 120 forks source link

OpenAPI schema results in errors when trying to access members in code #623

Closed brysontyrrell closed 2 months ago

brysontyrrell commented 2 months ago

Question

I'm posting this as a question as I'm not sure if it is a bug.

In a third-party OpenAPI document I am using there's a fairly complex response schema that matches on a discriminator mapped to others schemas, and those schemas use allOf with a ref to another shared schema and their own.

These are truncated for brevity, but below are how they've generated their OpenAPI doc and the resulting Swift code.

The base schema:

...
      "MobileDeviceResponse" : {
        "type" : "object",
        "discriminator" : {
          "propertyName" : "deviceType",
          "mapping" : {
            "iOS" : "#/components/schemas/MobileDeviceIosInventory",
            "tvOS" : "#/components/schemas/MobileDeviceTvOsInventory",
            "watchOS" : "#/components/schemas/MobileDeviceWatchOsInventory"
          }
        },
        "oneOf" : [ {
          "$ref" : "#/components/schemas/MobileDeviceIosInventory"
        }, {
          "$ref" : "#/components/schemas/MobileDeviceTvOsInventory"
        }, {
          "$ref" : "#/components/schemas/MobileDeviceWatchOsInventory"
        } ]
      },
...

One of the mapped schemas:

...
      "MobileDeviceIosInventory" : {
          "allOf" : [
              {
                  "$ref" : "#/components/schemas/MobileDeviceInventory"
              },
              {
                  "type" : "object",
                  "properties" : {
                      "general" : {
                          "$ref" : "#/components/schemas/MobileDeviceIosGeneral"
                      },
...

The MobileDeviceInventory schema that all three mapped schemas share and is the one that contains the discriminator:

...
      "MobileDeviceInventory" : {
        "required" : [ "deviceType" ],
        "type" : "object",
        "properties" : {
          "deviceType" : {
            "type" : "string",
            "description" : "Based on the value of this type either ios, appleTv, watch or visionOS objects will be populated.",
            "example" : "iOS"
          },
...

The generated Swift code in the same order:

...
        internal enum MobileDeviceResponse: Codable, Hashable, Sendable {
            /// - Remark: Generated from `#/components/schemas/MobileDeviceResponse/MobileDeviceIosInventory`.
            case iOS(Components.Schemas.MobileDeviceIosInventory)
            /// - Remark: Generated from `#/components/schemas/MobileDeviceResponse/MobileDeviceTvOsInventory`.
            case tvOS(Components.Schemas.MobileDeviceTvOsInventory)
            /// - Remark: Generated from `#/components/schemas/MobileDeviceResponse/MobileDeviceWatchOsInventory`.
            case watchOS(Components.Schemas.MobileDeviceWatchOsInventory)
            internal enum CodingKeys: String, CodingKey {
                case deviceType
            }
            internal init(from decoder: any Decoder) throws {
                let container = try decoder.container(keyedBy: CodingKeys.self)
                let discriminator = try container.decode(
                    Swift.String.self,
                    forKey: .deviceType
                )
                switch discriminator {
                case "iOS":
                    self = .iOS(try .init(from: decoder))
                case "tvOS":
                    self = .tvOS(try .init(from: decoder))
                case "watchOS":
                    self = .watchOS(try .init(from: decoder))
...
...
        internal struct MobileDeviceIosInventory: Codable, Hashable, Sendable {
            /// - Remark: Generated from `#/components/schemas/MobileDeviceIosInventory/value1`.
            internal var value1: Components.Schemas.MobileDeviceInventory
            /// - Remark: Generated from `#/components/schemas/MobileDeviceIosInventory/value2`.
            internal struct Value2Payload: Codable, Hashable, Sendable {
                // internal struct code here
            /// - Remark: Generated from `#/components/schemas/MobileDeviceIosInventory/value2`.
            internal var value2: Components.Schemas.MobileDeviceIosInventory.Value2Payload
            /// Creates a new `MobileDeviceIosInventory`.
            ///
            /// - Parameters:
            ///   - value1:
            ///   - value2:
            internal init(
                value1: Components.Schemas.MobileDeviceInventory,
                value2: Components.Schemas.MobileDeviceIosInventory.Value2Payload
            ) {
                self.value1 = value1
                self.value2 = value2
            }
...

MobileDeviceInventory is the schema that contains the deviceType property used by the outer discriminator.

In Xcode when I try to interact with the response from a request the Swift compiler throws errors that the members I'm trying to access don't exist. I can verify that the request response has decoded to the generated struct correctly. If I print out the object I can inspect the structure:

iOS(
    Project.Components.Schemas.MobileDeviceIosInventory(
        value1: Project.Components.Schemas.MobileDeviceInventory(
            deviceType: "iOS",
...

I would expect to be able to reach that property (and others) at response.value.deviceType but I instead get a compiler error:

Value of type 'Components.Schemas.MobileDeviceResponse' has no member 'value1'

The operation returns a MobileDeviceResponse, but that response will init into another type based on the discriminator, but Swift can't figure out what the possible properties are.

How do I work with this response so I can test which device type came back to access the appropriate properties and satisfy the compiler checks?

brysontyrrell commented 2 months ago

🤦‍♂️ And I did figure it out after writing everything out for this issue.

for device in results{
    switch device {
    case .iOS(let device):
        print(device.value1. deviceType!)
    case .tvOS(let device):
        print(device.value1. deviceType!)
    case .watchOS(let device):
        print(device.value1. deviceType!)
    default:
        print("UNKNOWN")
    }
}

Now that I understand I can work with the types, but I do wish there was a way for that outermost type to reflect the underlying type.

simonjbeaumont commented 2 months ago

Right, as you discovered, this results in a Swift enum that you'll need to switch over.

We offer some syntactic sugar on the output type enums to get-an-expected-case-or-throw, but we don't do that for all enums.