Skip to content

Support nullable values #82

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
m4p opened this issue Jun 20, 2023 · 17 comments · Fixed by #248
Closed

Support nullable values #82

m4p opened this issue Jun 20, 2023 · 17 comments · Fixed by #248
Labels
area/openapi Adding/updating a feature defined in OpenAPI. kind/enhancement Improvements to existing feature. size/S Small task. (A couple of hours of work.)
Milestone

Comments

@m4p
Copy link

m4p commented Jun 20, 2023

Commit hash: f530c90

Context:
While testing my application that uses with swift-openapi-generator, I noticed that nullable is not yet supported on Schema Objects values. This leads to a Decoder error for API calls that return null for nullable values.

Steps to reproduce:

  1. Use a OpenAPI spec that relies on nullable values
  2. Make an api call that returns some null values
  3. Expected: Nullable values returned as optionals
  4. Actual: DecodingError: valueNotFound String - Expected String value but found null instead.

$ swift --version
swift-driver version: 1.75.2 Apple Swift version 5.8.1 (swiftlang-5.8.0.124.5 clang-1403.0.22.11.100)
Target: arm64-apple-macosx13.0

$ uname -a
Darwin hayai.lan 22.5.0 Darwin Kernel Version 22.5.0: Mon Apr 24 20:52:24 PDT 2023; root:xnu-8796.121.2~5/RELEASE_ARM64_T6000 arm64

My system has IPv6 enabled.

@czechboy0
Copy link
Contributor

Hi @m4p, thanks for taking the time to file the issue.

Could you attach a short snippet of OpenAPI (ideally YAML) that shows more of the context of how you use nullable values? My understanding is that object properties not being required, and a schema being nullable, are similar in the sense that we want an optional value in Swift. (@mattpolzin can add more background here).

With the OpenAPI snippet, I'm also curious how you think the generated Swift code should change, to help us understand how and whether we need to treat non-required object properties and nullable schemas differently. Thanks! 🙏

@m4p
Copy link
Author

m4p commented Jun 20, 2023

Sure thing. I'm working with this third-party spec. I can try making the types non-required and see if that has the desired effects.

@m4p
Copy link
Author

m4p commented Jun 20, 2023

Yeah, removing nullable values from required, results in the code working as expected, so it may be an issue with the quality of the spec.

@czechboy0
Copy link
Contributor

I believe it's legal to have a required property marked as nullable, it's just a question of how to represent that well in Swift, where {} and {"foo": null} is treated the same way for a Swift Decodable struct that looks like:

struct Foo: Decodable {
  var foo: String?
}

I suspect this is a genuine thing we should handle in Swift OpenAPI Generator, by not just checking whether a property schema is required, but also whether it's nullable.

Just to confirm my suspicion, the code that emits the error you had in the issue description, is that property generated as foo: String or foo: String?.

@czechboy0 czechboy0 added area/openapi Adding/updating a feature defined in OpenAPI. kind/enhancement Improvements to existing feature. size/M Medium task. (A couple of days of work.) labels Jun 20, 2023
@mattpolzin
Copy link

mattpolzin commented Jun 20, 2023

Yeah, required nullable (and all 3 other permutations) are all valid. In my own API development I've even used these permutations to drive home the semantics of the API even though a client might handle "null" in the same way as the given property being omitted. For this reason, I've always preferred to distinguish between a property being there or not and a property being null or not in server code that I write even though that isn't particularly natural with Swift when encoding or decoding.

@czechboy0
Copy link
Contributor

czechboy0 commented Jun 20, 2023

Thanks for the confirmation, @mattpolzin. So this is a task on Swift OpenAPI Generator. Presumably, we should create a utility method on an object schema that can tell us whether a property should be generated as an optional type, and it'll return true if either the schema is nullable, or the property name is not included in the required array on the object schema. And then replace all occurrences of checking required by using this utility method.

@czechboy0 czechboy0 added size/S Small task. (A couple of hours of work.) and removed size/M Medium task. (A couple of days of work.) labels Jun 20, 2023
@m4p
Copy link
Author

m4p commented Jun 20, 2023

@czechboy0 It generates public var next_url: Swift.String for the line linked in the spec above. Non-Optional.

@czechboy0
Copy link
Contributor

@m4p Great, that matches my expectations. So if anyone would like to contribute a fix, this comment outlines the rough steps: #82 (comment)

@Kyle-Ye
Copy link
Contributor

Kyle-Ye commented Jun 27, 2023

I'm experience the same problem.

Consider the following input

groups:
  type: array
  items:
    type: object
    additionalProperties: false
    properties:
      id:
        type: integer
      flair_url:
        type:
        - string
        - 'null'
    required:
    - id
    - flair_url

The current output is the following

public struct groupsPayloadPayload: Codable, Equatable, Hashable, Sendable {
    /// - Remark: Generated from `#/paths/site.json/GET/json/groupsPayload/id`.
    public var id: Swift.Int
    /// - Remark: Generated from `#/paths/site.json/GET/json/groupsPayload/flair_url`.
    public var flair_url: Swift.String
    /// Creates a new `groupsPayloadPayload`.
    ///
    /// - Parameters:
    ///   - id:
    ///   - flair_url:
    public init(
        id: Swift.Int,
        flair_url: Swift.String
    ) {
        self.id = id
        self.flair_url = flair_url
    }
    public enum CodingKeys: String, CodingKey {
        case id
        case flair_url
    }
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(Swift.Int.self, forKey: .id)
        flair_url = try container.decode(
            Swift.String.self,
            forKey: .flair_url
        )
        try decoder.ensureNoAdditionalProperties(knownKeys: [
            "id", "flair_url", "flair_bg_color", "flair_color",
        ])
    }
}

The expected output is

public init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    id = try container.decode(Swift.Int.self, forKey: .id)
    // ❌ since flair_url is marked as required
    flair_url = try? container.decode(Swift.String.self, forKey: .flair_url)
    // ✅ Only String or nil is accepted
    flair_url = try container.decode(Swift.String?.self, forKey: .flair_url)
    try decoder.ensureNoAdditionalProperties(knownKeys: [
        "id", "flair_url", "flair_bg_color", "flair_color",
    ])
}

Here we treat null as a special case. But what about the following input? (We have random types combined)

groups:
  type: array
  items:
    type: object
    additionalProperties: false
    properties:
      id:
        type: integer
      flair_url:
        type:
        - string
        - integer
    required:
    - id
    - flair_url

@Kyle-Ye
Copy link
Contributor

Kyle-Ye commented Jun 27, 2023

Thanks for the confirmation, @mattpolzin. So this is a task on Swift OpenAPI Generator. Presumably, we should create a utility method on an object schema that can tell us whether a property should be generated as an optional type, and it'll return true if either the schema is nullable, or the property name is not included in the required array on the object schema. And then replace all occurrences of checking required by using this utility method.

We need to diff requirement and nullability.

A non-required type can infer nullability, but nullability can't infer requirement info.

Consider the following define and input

// name non-required
define:
items:
  type: object
  properties:
    name:
      type:
      - string
  required:
accepted input:
- { "name": "xx" }
- {}
code:
try decodeIfPresent(String.self, forKey: .name)
// name required & nullable
define:
items:
  type: object
  properties:
    name:
      type:
      - string
      - 'null'
  required:
  - name
accepted input:
- { "name": "xx" }
- { "name": nil }
code:
try decode(String?.self, forKey: .name)
> Or we might make it more general to support any type combination
> try decode(A.self, forKey: .name) and try decode(B.self, forKey: .name)
// name required & nonnull
define:
items:
  type: object
  properties:
    name:
      type:
      - string
  required:
  - name
accepted input:
- { "name": "xx" }
code:
try decode(String.self, forKey: .name)

And I think it was not a good idea to use try? decode which will not throw an error even the type is not match.(eg. Expected Int but actually a String)

@Kyle-Ye
Copy link
Contributor

Kyle-Ye commented Jun 27, 2023

Thanks for the confirmation, @mattpolzin. So this is a task on Swift OpenAPI Generator. Presumably, we should create a utility method on an object schema that can tell us whether a property should be generated as an optional type, and it'll return true if either the schema is nullable, or the property name is not included in the required array on the object schema. And then replace all occurrences of checking required by using this utility method.

A more detailed hint for anyone who'd like to submit a fix.

The fix will probably involve the following code

And you may need to add a fullyQualifiedOptionalSwiftName property for TypeUsage type

/// A string representation of the fully qualified Swift type name, with
/// optional wrapping added.
///
/// For example: `Swift.Int?`.
var fullyQualifiedOptionalSwiftName: String {
    withOptional(true).fullyQualifiedSwiftName
}

@mattdw
Copy link

mattdw commented Jul 3, 2023

I think I've hit a related problem - this spec (which I don't control), specifies enums like:

"refund_behaviour": {
  "type": "string",
  "nullable": true,
  "description": "How the category's refunds or deductions should be reported on.",
  "enum": [
    "debits_are_deductions",
    "credits_are_refunds",
    null
  ],
  "example": "credits_are_refunds"
},

Which then results in errors like:

Error: Disallowed value for a string enum 'Components.Schemas.Category.refund_behaviourPayload (#/components/schemas/Category/refund_behaviour)': ()

(For the time being I've just vendored and edited the spec, as this is a hobby project just to poke at OpenAPI via Swift.)

@simonjbeaumont
Copy link
Collaborator

@mattdw that's interesting. We added support for the empty value in a string enum in #59.

If you have the time, could you let us know if you're using a version with this fix and, if you're still having issues, could you open a new issue so we can investigate? 🙏

@czechboy0
Copy link
Contributor

czechboy0 commented Jul 11, 2023

We added support for the empty value in a string enum in #59.

We did, but this is subtly different. With #59, we now support:

type: string
enum:
  - foo
  - ""

But the above is:

type: string
nullable: true
enum:
  - foo
  - null

We carefully have to design support for nullable types, as they are distinct from (non-)required properties on object schemas; yet, in Swift, they probably make sense to look very similar.

Some additional reading:

We'll need a proposal for this, for sure, and possibly even more than one. I imagine we could first propose to support nullable types as object properties, then as enum values, etc, as they might be handled differently.

The main question I'd ask anyone considering taking this on is: what should the generated code look and behave like? Once we agree on that, we can help guide the implementation.

czechboy0 added a commit that referenced this issue Aug 8, 2023
Nullable enums with an empty string fail to get generated

### Motivation

Fixes #118.

The issue is that when a `type: string, enum: ...` schema is marked as `nullable: true`, _empty cases are parsed as Void_ instead of _nil_. See the issue for the investigation and identifying the root cause, but the TL;DR is that we need a workaround in the generator in the short term, and it's uncertain if the issue will ever be addressed in our dependency Yams (might be unlikely, as it'd probably be a breaking change for them at this point).

### Modifications

This PR adds code that catches this case and converts the `Void` into an empty string, which is what the OpenAPI document author actually put into their document in the first place.

### Result

Now `nullable: true` string enums with an empty case can be generated correctly. One example of such use is the [GitHub API](https://github.com/github/rest-api-description).

Note that this PR isn't really adding support for nullable schemas in general, that's tracked by #82. We're just working around one specific scenario in which a relatively common input trips up the generator.

### Test Plan

Added a unit test for each of: non-nullable and nullable variants.


Reviewed by: gjcairo, simonjbeaumont

Builds:
     ✔︎ pull request validation (5.8) - Build finished. 
     ✔︎ pull request validation (5.9) - Build finished. 
     ✔︎ pull request validation (docc test) - Build finished. 
     ✔︎ pull request validation (integration test) - Build finished. 
     ✔︎ pull request validation (nightly) - Build finished. 
     ✔︎ pull request validation (soundness) - Build finished. 

#176
@czechboy0 czechboy0 changed the title Support nullable values for Schema Objects Support nullable values Aug 23, 2023
@czechboy0 czechboy0 added this to the 1.0 milestone Aug 25, 2023
czechboy0 added a commit that referenced this issue Sep 6, 2023
### Motivation

Fixes #82.

More background:

In JSON Schema, there are two fields that affect what we represent as
optionality in Swift.
1. In object schemas, not including a property in the `required` array.
2. In all schemas, marking the schema as nullable. In OpenAPI 3.0, that
used to be done using a dedicated `nullable` field, in OpenAPI 3.1, that
field is removed and instead the same result is achieved by adding the
`null` type in the `type` array. So for a non-nullable string, you'd use
`type: string`, and for a nullable string, you'd use `type: [string,
null]`.

Up until now, we've only supported (1) in how we decide which types are
generated as optional, and that seems to be how most adopters control
nullability. However, (2) is also supported in JSON Schema, so we should
also support it, and as evidenced by the comments in #82, a few folks
have hit the lack of nullability support in Swift OpenAPI Generator.

### Modifications

This PR takes nullability on the schema into account when deciding
whether to generate a type as optional or not.

Since this is a source-breaking change, it's introduced behind the
feature flag `nullableSchemas`, and it'll become default behavior in
0.3.0.

Most of this change is just propagating the feature flag to the right
place.

### Result

Marking a schema as nullable will result in an optional type being
generated.

### Test Plan

Added a snippet test, both with and without the feature flag, which
shows the change.
@Kyle-Ye
Copy link
Contributor

Kyle-Ye commented Sep 17, 2023

Tested with the following and still failed to decode.

i18n_name:
  type:
  - string
  - 'null'

Since there is some unsolved case for the issue, maybe we should consider reopen it?

@czechboy0
Copy link
Contributor

@Kyle-Ye are you including the nullableSchemas feature flag?

@Kyle-Ye
Copy link
Contributor

Kyle-Ye commented Sep 17, 2023

@Kyle-Ye are you including the nullableSchemas feature flag?

No. After I enable it, it did solve the problem. Thanks for the clarification.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area/openapi Adding/updating a feature defined in OpenAPI. kind/enhancement Improvements to existing feature. size/S Small task. (A couple of hours of work.)
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants