Skip to content

Commit 76994bf

Browse files
Generate arrays with an optional element type for arrays with nullable 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.
1 parent cd88cbe commit 76994bf

File tree

4 files changed

+181
-6
lines changed

4 files changed

+181
-6
lines changed

Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ struct TypeMatcher {
4444
Self._tryMatchRecursive(
4545
for: schema,
4646
test: { schema in Self._tryMatchBuiltinNonRecursive(for: schema) },
47-
matchedArrayHandler: { elementType in elementType.asArray },
47+
matchedArrayHandler: { elementType, nullableItems in
48+
nullableItems ? elementType.asOptional.asArray : elementType.asArray
49+
},
4850
genericArrayHandler: { TypeName.arrayContainer.asUsage }
4951
)
5052
}
@@ -71,7 +73,9 @@ struct TypeMatcher {
7173
guard case let .reference(ref, _) = schema else { return nil }
7274
return try TypeAssigner(asSwiftSafeName: asSwiftSafeName).typeName(for: ref).asUsage
7375
},
74-
matchedArrayHandler: { elementType in elementType.asArray },
76+
matchedArrayHandler: { elementType, nullableItems in
77+
nullableItems ? elementType.asOptional.asArray : elementType.asArray
78+
},
7579
genericArrayHandler: { TypeName.arrayContainer.asUsage }
7680
)?
7781
.withOptional(isOptional(schema, components: components))
@@ -94,7 +98,7 @@ struct TypeMatcher {
9498
guard case .reference = schema else { return false }
9599
return true
96100
},
97-
matchedArrayHandler: { elementIsReferenceable in elementIsReferenceable },
101+
matchedArrayHandler: { elementIsReferenceable, _ in elementIsReferenceable },
98102
genericArrayHandler: { true }
99103
) ?? false
100104
}
@@ -351,7 +355,7 @@ struct TypeMatcher {
351355
private static func _tryMatchRecursive<R>(
352356
for schema: JSONSchema.Schema,
353357
test: (JSONSchema.Schema) throws -> R?,
354-
matchedArrayHandler: (R) -> R,
358+
matchedArrayHandler: (R, _ nullableItems: Bool) -> R,
355359
genericArrayHandler: () -> R
356360
) rethrows -> R? {
357361
switch schema {
@@ -365,7 +369,7 @@ struct TypeMatcher {
365369
genericArrayHandler: genericArrayHandler
366370
)
367371
else { return nil }
368-
return matchedArrayHandler(itemsResult)
372+
return matchedArrayHandler(itemsResult, items.nullable)
369373
default: return try test(schema)
370374
}
371375
}

Tests/OpenAPIGeneratorReferenceTests/Resources/Docs/petstore.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,13 @@ components:
399399
type: string
400400
additionalProperties:
401401
type: integer
402+
ObjectWithOptionalNullableArrayOfNullableItems:
403+
type: object
404+
properties:
405+
foo:
406+
type: [array, null]
407+
items:
408+
type: [string, null]
402409
CodeError:
403410
type: object
404411
properties:

Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Types.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,21 @@ public enum Components {
619619
try encoder.encodeAdditionalProperties(additionalProperties)
620620
}
621621
}
622+
/// - Remark: Generated from `#/components/schemas/ObjectWithOptionalNullableArrayOfNullableItems`.
623+
public struct ObjectWithOptionalNullableArrayOfNullableItems: Codable, Hashable, Sendable {
624+
/// - Remark: Generated from `#/components/schemas/ObjectWithOptionalNullableArrayOfNullableItems/foo`.
625+
public var foo: [Swift.String?]?
626+
/// Creates a new `ObjectWithOptionalNullableArrayOfNullableItems`.
627+
///
628+
/// - Parameters:
629+
/// - foo:
630+
public init(foo: [Swift.String?]? = nil) {
631+
self.foo = foo
632+
}
633+
public enum CodingKeys: String, CodingKey {
634+
case foo
635+
}
636+
}
622637
/// - Remark: Generated from `#/components/schemas/CodeError`.
623638
public struct CodeError: Codable, Hashable, Sendable {
624639
/// - Remark: Generated from `#/components/schemas/CodeError/code`.

Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,66 @@ final class SnippetBasedReferenceTests: XCTestCase {
164164
)
165165
}
166166

167+
func testComponentsSchemasNullableString() throws {
168+
try self.assertSchemasTranslation(
169+
"""
170+
schemas:
171+
MyString:
172+
type: string
173+
""",
174+
// NOTE: We don't generate a typealias to an optional; instead nullable is considered at point of use.
175+
"""
176+
public enum Schemas {
177+
public typealias MyString = Swift.String
178+
}
179+
"""
180+
)
181+
}
182+
183+
func testComponentsSchemasArrayWithNullableItems() throws {
184+
try self.assertSchemasTranslation(
185+
"""
186+
schemas:
187+
StringArray:
188+
type: array
189+
items:
190+
type: string
191+
192+
StringArrayNullableItems:
193+
type: array
194+
items:
195+
type: [string, null]
196+
""",
197+
"""
198+
public enum Schemas {
199+
public typealias StringArray = [Swift.String]
200+
public typealias StringArrayNullableItems = [Swift.String?]
201+
}
202+
"""
203+
)
204+
}
205+
206+
func testComponentsSchemasArrayOfRefsOfNullableItems() throws {
207+
try XCTSkipIf(true, "TODO: Still need to propagate nullability through reference at time of use")
208+
try self.assertSchemasTranslation(
209+
"""
210+
schemas:
211+
ArrayOfRefsToNullableItems:
212+
type: array
213+
items:
214+
$ref: '#/components/schemas/NullableString'
215+
NullableString:
216+
type: [string, null]
217+
""",
218+
"""
219+
public enum Schemas {
220+
public typealias ArrayOfRefsToNullableItems = [Components.Schemas.NullableString?]
221+
public typealias NullableString = Swift.String
222+
}
223+
"""
224+
)
225+
}
226+
167227
func testComponentsSchemasNullableStringProperty() throws {
168228
try self.assertSchemasTranslation(
169229
"""
@@ -179,9 +239,47 @@ final class SnippetBasedReferenceTests: XCTestCase {
179239
type: [string, null]
180240
fooRequiredNullable:
181241
type: [string, null]
242+
243+
fooOptionalArray:
244+
type: array
245+
items:
246+
type: string
247+
fooRequiredArray:
248+
type: array
249+
items:
250+
type: string
251+
fooOptionalNullableArray:
252+
type: [array, null]
253+
items:
254+
type: string
255+
fooRequiredNullableArray:
256+
type: [array, null]
257+
items:
258+
type: string
259+
260+
fooOptionalArrayOfNullableItems:
261+
type: array
262+
items:
263+
type: [string, null]
264+
fooRequiredArrayOfNullableItems:
265+
type: array
266+
items:
267+
type: [string, null]
268+
fooOptionalNullableArrayOfNullableItems:
269+
type: [array, null]
270+
items:
271+
type: [string, null]
272+
fooRequiredNullableArrayOfNullableItems:
273+
type: [array, null]
274+
items:
275+
type: [string, null]
182276
required:
183277
- fooRequired
184278
- fooRequiredNullable
279+
- fooRequiredArray
280+
- fooRequiredNullableArray
281+
- fooRequiredArrayOfNullableItems
282+
- fooRequiredNullableArrayOfNullableItems
185283
""",
186284
"""
187285
public enum Schemas {
@@ -190,29 +288,80 @@ final class SnippetBasedReferenceTests: XCTestCase {
190288
public var fooRequired: Swift.String
191289
public var fooOptionalNullable: Swift.String?
192290
public var fooRequiredNullable: Swift.String?
291+
public var fooOptionalArray: [Swift.String]?
292+
public var fooRequiredArray: [Swift.String]
293+
public var fooOptionalNullableArray: [Swift.String]?
294+
public var fooRequiredNullableArray: [Swift.String]?
295+
public var fooOptionalArrayOfNullableItems: [Swift.String?]?
296+
public var fooRequiredArrayOfNullableItems: [Swift.String?]
297+
public var fooOptionalNullableArrayOfNullableItems: [Swift.String?]?
298+
public var fooRequiredNullableArrayOfNullableItems: [Swift.String?]?
193299
public init(
194300
fooOptional: Swift.String? = nil,
195301
fooRequired: Swift.String,
196302
fooOptionalNullable: Swift.String? = nil,
197-
fooRequiredNullable: Swift.String? = nil
303+
fooRequiredNullable: Swift.String? = nil,
304+
fooOptionalArray: [Swift.String]? = nil,
305+
fooRequiredArray: [Swift.String],
306+
fooOptionalNullableArray: [Swift.String]? = nil,
307+
fooRequiredNullableArray: [Swift.String]? = nil,
308+
fooOptionalArrayOfNullableItems: [Swift.String?]? = nil,
309+
fooRequiredArrayOfNullableItems: [Swift.String?],
310+
fooOptionalNullableArrayOfNullableItems: [Swift.String?]? = nil,
311+
fooRequiredNullableArrayOfNullableItems: [Swift.String?]? = nil
198312
) {
199313
self.fooOptional = fooOptional
200314
self.fooRequired = fooRequired
201315
self.fooOptionalNullable = fooOptionalNullable
202316
self.fooRequiredNullable = fooRequiredNullable
317+
self.fooOptionalArray = fooOptionalArray
318+
self.fooRequiredArray = fooRequiredArray
319+
self.fooOptionalNullableArray = fooOptionalNullableArray
320+
self.fooRequiredNullableArray = fooRequiredNullableArray
321+
self.fooOptionalArrayOfNullableItems = fooOptionalArrayOfNullableItems
322+
self.fooRequiredArrayOfNullableItems = fooRequiredArrayOfNullableItems
323+
self.fooOptionalNullableArrayOfNullableItems = fooOptionalNullableArrayOfNullableItems
324+
self.fooRequiredNullableArrayOfNullableItems = fooRequiredNullableArrayOfNullableItems
203325
}
204326
public enum CodingKeys: String, CodingKey {
205327
case fooOptional
206328
case fooRequired
207329
case fooOptionalNullable
208330
case fooRequiredNullable
331+
case fooOptionalArray
332+
case fooRequiredArray
333+
case fooOptionalNullableArray
334+
case fooRequiredNullableArray
335+
case fooOptionalArrayOfNullableItems
336+
case fooRequiredArrayOfNullableItems
337+
case fooOptionalNullableArrayOfNullableItems
338+
case fooRequiredNullableArrayOfNullableItems
209339
}
210340
}
211341
}
212342
"""
213343
)
214344
}
215345

346+
func testEncodingDecodingArrayWithNullableItems() throws {
347+
struct MyObject: Codable, Equatable {
348+
let myArray: [String?]?
349+
350+
var json: String { get throws { try String(data: JSONEncoder().encode(self), encoding: .utf8)! } }
351+
352+
static func from(json: String) throws -> Self { try JSONDecoder().decode(Self.self, from: Data(json.utf8)) }
353+
}
354+
355+
for (value, encoding) in [
356+
(MyObject(myArray: nil), #"{}"#), (MyObject(myArray: []), #"{"myArray":[]}"#),
357+
(MyObject(myArray: ["a"]), #"{"myArray":["a"]}"#), (MyObject(myArray: [nil]), #"{"myArray":[null]}"#),
358+
(MyObject(myArray: ["a", nil]), #"{"myArray":["a",null]}"#),
359+
] {
360+
XCTAssertEqual(try value.json, encoding)
361+
XCTAssertEqual(try MyObject.from(json: value.json), value)
362+
}
363+
}
364+
216365
func testComponentsSchemasObjectWithInferredProperty() throws {
217366
try self.assertSchemasTranslation(
218367
ignoredDiagnosticMessages: [

0 commit comments

Comments
 (0)