Skip to content

Commit

Permalink
Generate arrays with an optional element type for arrays with nullabl…
Browse files Browse the repository at this point in the history
…e items (#492)

### Motivation

Array items can be nullable. When this is the case, the element type of
the generated array should be optional. Today it is not. For example,
consider the following OpenAPI snippet:

```yaml
  StringArrayNullableItems:
    type: array
    items:
      type: [string, null]
```

This currently generates a type alias for `[String]` but it should be
`[String?]`

### Modifications

Update the translator to take into account `arrayContext.items.nullable`
when generating the types for array values.

### Result

Arrays with nullable items 

### Test Plan

- Snippet test for standalone schema.
- Snippet test for use within an object.
- A test that shows lossless conversion to JSON with Foundation.

### Related issues

- Fixes #444.
  • Loading branch information
simonjbeaumont committed Dec 15, 2023
1 parent cd88cbe commit 76994bf
Show file tree
Hide file tree
Showing 4 changed files with 181 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ struct TypeMatcher {
Self._tryMatchRecursive(
for: schema,
test: { schema in Self._tryMatchBuiltinNonRecursive(for: schema) },
matchedArrayHandler: { elementType in elementType.asArray },
matchedArrayHandler: { elementType, nullableItems in
nullableItems ? elementType.asOptional.asArray : elementType.asArray
},
genericArrayHandler: { TypeName.arrayContainer.asUsage }
)
}
Expand All @@ -71,7 +73,9 @@ struct TypeMatcher {
guard case let .reference(ref, _) = schema else { return nil }
return try TypeAssigner(asSwiftSafeName: asSwiftSafeName).typeName(for: ref).asUsage
},
matchedArrayHandler: { elementType in elementType.asArray },
matchedArrayHandler: { elementType, nullableItems in
nullableItems ? elementType.asOptional.asArray : elementType.asArray
},
genericArrayHandler: { TypeName.arrayContainer.asUsage }
)?
.withOptional(isOptional(schema, components: components))
Expand All @@ -94,7 +98,7 @@ struct TypeMatcher {
guard case .reference = schema else { return false }
return true
},
matchedArrayHandler: { elementIsReferenceable in elementIsReferenceable },
matchedArrayHandler: { elementIsReferenceable, _ in elementIsReferenceable },
genericArrayHandler: { true }
) ?? false
}
Expand Down Expand Up @@ -351,7 +355,7 @@ struct TypeMatcher {
private static func _tryMatchRecursive<R>(
for schema: JSONSchema.Schema,
test: (JSONSchema.Schema) throws -> R?,
matchedArrayHandler: (R) -> R,
matchedArrayHandler: (R, _ nullableItems: Bool) -> R,
genericArrayHandler: () -> R
) rethrows -> R? {
switch schema {
Expand All @@ -365,7 +369,7 @@ struct TypeMatcher {
genericArrayHandler: genericArrayHandler
)
else { return nil }
return matchedArrayHandler(itemsResult)
return matchedArrayHandler(itemsResult, items.nullable)
default: return try test(schema)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,13 @@ components:
type: string
additionalProperties:
type: integer
ObjectWithOptionalNullableArrayOfNullableItems:
type: object
properties:
foo:
type: [array, null]
items:
type: [string, null]
CodeError:
type: object
properties:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,21 @@ public enum Components {
try encoder.encodeAdditionalProperties(additionalProperties)
}
}
/// - Remark: Generated from `#/components/schemas/ObjectWithOptionalNullableArrayOfNullableItems`.
public struct ObjectWithOptionalNullableArrayOfNullableItems: Codable, Hashable, Sendable {
/// - Remark: Generated from `#/components/schemas/ObjectWithOptionalNullableArrayOfNullableItems/foo`.
public var foo: [Swift.String?]?
/// Creates a new `ObjectWithOptionalNullableArrayOfNullableItems`.
///
/// - Parameters:
/// - foo:
public init(foo: [Swift.String?]? = nil) {
self.foo = foo
}
public enum CodingKeys: String, CodingKey {
case foo
}
}
/// - Remark: Generated from `#/components/schemas/CodeError`.
public struct CodeError: Codable, Hashable, Sendable {
/// - Remark: Generated from `#/components/schemas/CodeError/code`.
Expand Down
151 changes: 150 additions & 1 deletion Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,66 @@ final class SnippetBasedReferenceTests: XCTestCase {
)
}

func testComponentsSchemasNullableString() throws {
try self.assertSchemasTranslation(
"""
schemas:
MyString:
type: string
""",
// NOTE: We don't generate a typealias to an optional; instead nullable is considered at point of use.
"""
public enum Schemas {
public typealias MyString = Swift.String
}
"""
)
}

func testComponentsSchemasArrayWithNullableItems() throws {
try self.assertSchemasTranslation(
"""
schemas:
StringArray:
type: array
items:
type: string
StringArrayNullableItems:
type: array
items:
type: [string, null]
""",
"""
public enum Schemas {
public typealias StringArray = [Swift.String]
public typealias StringArrayNullableItems = [Swift.String?]
}
"""
)
}

func testComponentsSchemasArrayOfRefsOfNullableItems() throws {
try XCTSkipIf(true, "TODO: Still need to propagate nullability through reference at time of use")
try self.assertSchemasTranslation(
"""
schemas:
ArrayOfRefsToNullableItems:
type: array
items:
$ref: '#/components/schemas/NullableString'
NullableString:
type: [string, null]
""",
"""
public enum Schemas {
public typealias ArrayOfRefsToNullableItems = [Components.Schemas.NullableString?]
public typealias NullableString = Swift.String
}
"""
)
}

func testComponentsSchemasNullableStringProperty() throws {
try self.assertSchemasTranslation(
"""
Expand All @@ -179,9 +239,47 @@ final class SnippetBasedReferenceTests: XCTestCase {
type: [string, null]
fooRequiredNullable:
type: [string, null]
fooOptionalArray:
type: array
items:
type: string
fooRequiredArray:
type: array
items:
type: string
fooOptionalNullableArray:
type: [array, null]
items:
type: string
fooRequiredNullableArray:
type: [array, null]
items:
type: string
fooOptionalArrayOfNullableItems:
type: array
items:
type: [string, null]
fooRequiredArrayOfNullableItems:
type: array
items:
type: [string, null]
fooOptionalNullableArrayOfNullableItems:
type: [array, null]
items:
type: [string, null]
fooRequiredNullableArrayOfNullableItems:
type: [array, null]
items:
type: [string, null]
required:
- fooRequired
- fooRequiredNullable
- fooRequiredArray
- fooRequiredNullableArray
- fooRequiredArrayOfNullableItems
- fooRequiredNullableArrayOfNullableItems
""",
"""
public enum Schemas {
Expand All @@ -190,29 +288,80 @@ final class SnippetBasedReferenceTests: XCTestCase {
public var fooRequired: Swift.String
public var fooOptionalNullable: Swift.String?
public var fooRequiredNullable: Swift.String?
public var fooOptionalArray: [Swift.String]?
public var fooRequiredArray: [Swift.String]
public var fooOptionalNullableArray: [Swift.String]?
public var fooRequiredNullableArray: [Swift.String]?
public var fooOptionalArrayOfNullableItems: [Swift.String?]?
public var fooRequiredArrayOfNullableItems: [Swift.String?]
public var fooOptionalNullableArrayOfNullableItems: [Swift.String?]?
public var fooRequiredNullableArrayOfNullableItems: [Swift.String?]?
public init(
fooOptional: Swift.String? = nil,
fooRequired: Swift.String,
fooOptionalNullable: Swift.String? = nil,
fooRequiredNullable: Swift.String? = nil
fooRequiredNullable: Swift.String? = nil,
fooOptionalArray: [Swift.String]? = nil,
fooRequiredArray: [Swift.String],
fooOptionalNullableArray: [Swift.String]? = nil,
fooRequiredNullableArray: [Swift.String]? = nil,
fooOptionalArrayOfNullableItems: [Swift.String?]? = nil,
fooRequiredArrayOfNullableItems: [Swift.String?],
fooOptionalNullableArrayOfNullableItems: [Swift.String?]? = nil,
fooRequiredNullableArrayOfNullableItems: [Swift.String?]? = nil
) {
self.fooOptional = fooOptional
self.fooRequired = fooRequired
self.fooOptionalNullable = fooOptionalNullable
self.fooRequiredNullable = fooRequiredNullable
self.fooOptionalArray = fooOptionalArray
self.fooRequiredArray = fooRequiredArray
self.fooOptionalNullableArray = fooOptionalNullableArray
self.fooRequiredNullableArray = fooRequiredNullableArray
self.fooOptionalArrayOfNullableItems = fooOptionalArrayOfNullableItems
self.fooRequiredArrayOfNullableItems = fooRequiredArrayOfNullableItems
self.fooOptionalNullableArrayOfNullableItems = fooOptionalNullableArrayOfNullableItems
self.fooRequiredNullableArrayOfNullableItems = fooRequiredNullableArrayOfNullableItems
}
public enum CodingKeys: String, CodingKey {
case fooOptional
case fooRequired
case fooOptionalNullable
case fooRequiredNullable
case fooOptionalArray
case fooRequiredArray
case fooOptionalNullableArray
case fooRequiredNullableArray
case fooOptionalArrayOfNullableItems
case fooRequiredArrayOfNullableItems
case fooOptionalNullableArrayOfNullableItems
case fooRequiredNullableArrayOfNullableItems
}
}
}
"""
)
}

func testEncodingDecodingArrayWithNullableItems() throws {
struct MyObject: Codable, Equatable {
let myArray: [String?]?

var json: String { get throws { try String(data: JSONEncoder().encode(self), encoding: .utf8)! } }

static func from(json: String) throws -> Self { try JSONDecoder().decode(Self.self, from: Data(json.utf8)) }
}

for (value, encoding) in [
(MyObject(myArray: nil), #"{}"#), (MyObject(myArray: []), #"{"myArray":[]}"#),
(MyObject(myArray: ["a"]), #"{"myArray":["a"]}"#), (MyObject(myArray: [nil]), #"{"myArray":[null]}"#),
(MyObject(myArray: ["a", nil]), #"{"myArray":["a",null]}"#),
] {
XCTAssertEqual(try value.json, encoding)
XCTAssertEqual(try MyObject.from(json: value.json), value)
}
}

func testComponentsSchemasObjectWithInferredProperty() throws {
try self.assertSchemasTranslation(
ignoredDiagnosticMessages: [
Expand Down

0 comments on commit 76994bf

Please sign in to comment.