Skip to content
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

Improvements to nullable references. #558

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ let package = Package(

// Read OpenAPI documents
.package(url: "https://github.com/mattpolzin/OpenAPIKit", from: "3.1.2"),
.package(url: "https://github.com/jpsim/Yams", "4.0.0"..<"6.0.0"),
.package(url: "https://github.com/jpsim/Yams", "5.1.0"..<"6.0.0"),

// CLI Tool
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ extension TypesFileTranslator {
/// - Throws: An error if there is an issue during translation.
/// - Returns: A declaration representing the translated allOf/anyOf structure.
func translateAllOrAnyOf(typeName: TypeName, openAPIDescription: String?, type: AllOrAnyOf, schemas: [JSONSchema])
throws -> Declaration
throws -> [Declaration]
{
let properties: [(property: PropertyBlueprint, isKeyValuePair: Bool)] = try schemas.enumerated()
.map { index, schema in
Expand Down Expand Up @@ -107,7 +107,7 @@ extension TypesFileTranslator {
properties: propertyValues
)
)
return structDecl
return [structDecl]
}

/// Returns a declaration for a oneOf schema.
Expand All @@ -128,7 +128,7 @@ extension TypesFileTranslator {
openAPIDescription: String?,
discriminator: OpenAPI.Discriminator?,
schemas: [JSONSchema]
) throws -> Declaration {
) throws -> [Declaration] {
let cases: [(String, [String]?, Bool, Comment?, TypeUsage, [Declaration])]
if let discriminator {
// > When using the discriminator, inline schemas will not be considered.
Expand All @@ -148,7 +148,15 @@ extension TypesFileTranslator {
return (caseName, mappedType.rawNames, true, comment, mappedType.typeName.asUsage, [])
}
} else {
cases = try schemas.enumerated()
let (schemas, nullSchemas) = schemas.partitioned(by: { typeMatcher.isNull($0) })
if schemas.count == 1, nullSchemas.count > 0, let schema = schemas.first {
return try translateSchema(
typeName: typeName,
schema: schema,
overrides: .init(isOptional: true))
}
cases = try schemas
.enumerated()
.map { index, schema in
let key = "case\(index+1)"
let childType = try typeAssigner.typeUsage(
Expand Down Expand Up @@ -242,6 +250,6 @@ extension TypesFileTranslator {
conformances: Constants.ObjectStruct.conformances,
members: caseDecls + codingKeysDecls + [decoder, encoder]
)
return .commentable(comment, enumDecl)
return [.commentable(comment, enumDecl)]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -141,29 +141,26 @@ extension TypesFileTranslator {
arrayContext: arrayContext
)
case let .all(of: schemas, core: coreContext):
let allOfDecl = try translateAllOrAnyOf(
return try translateAllOrAnyOf(
typeName: typeName,
openAPIDescription: overrides.userDescription ?? coreContext.description,
type: .allOf,
schemas: schemas
)
return [allOfDecl]
case let .any(of: schemas, core: coreContext):
let anyOfDecl = try translateAllOrAnyOf(
return try translateAllOrAnyOf(
typeName: typeName,
openAPIDescription: overrides.userDescription ?? coreContext.description,
type: .anyOf,
schemas: schemas
)
return [anyOfDecl]
case let .one(of: schemas, core: coreContext):
let oneOfDecl = try translateOneOf(
return try translateOneOf(
typeName: typeName,
openAPIDescription: overrides.userDescription ?? coreContext.description,
discriminator: coreContext.discriminator,
schemas: schemas
)
return [oneOfDecl]
default: return []
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ extension FileTranslator {
let typealiasDescription = TypealiasDescription(
accessModifier: config.access,
name: typeName.shortSwiftName,
existingType: .init(existingTypeUsage.withOptional(false))
existingType: .init(existingTypeUsage)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this change necessary?
Let's say I have an optional string in my schema:

"line2": {
	"title": "Line2",
	"oneOf": [
		{
			"type": "string",
		},
		{
			"type": "null"
		}
	]
}

It's not marked as required. Full json is attached.

So now, with this change, it generates a nested optional type:

/// - Remark: Generated from `#/components/schemas/Address/line2`.
public typealias line2Payload = Swift.String?
/// - Remark: Generated from `#/components/schemas/Address/line2`.
public var line2: Components.Schemas.Address.line2Payload?

And if we have an optional struct, not a String, it also generates a nested optional, so we have to access its values using generatedObject??.property which is a bit weird.

Without this change, everything seems to work. I didn't find an answer in test cases as well. I was thinking about forking and undoing this change, but maybe I'm missing something, so what's behind that?

P.S. Great job @brandonbloom. I have no idea how much effort is needed to support anyOf/allOf correctly. But in our project we probably need only oneOf for optionals, so this PR is a really nice work.

)
let typealiasComment: Comment? = typeName.docCommentWithUserDescription(userDescription)
return .commentable(typealiasComment, .typealias(typealiasDescription))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ extension TypesFileTranslator {
let decl = try translateSchema(
typeName: typeName,
schema: parameter.schema,
overrides: .init(isOptional: !parameter.required, userDescription: parameter.parameter.description)
overrides: .init(userDescription: parameter.parameter.description)
)
return decl
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ extension TypesFileTranslator {
let decl = try translateSchema(
typeName: typeName,
schema: header.schema,
overrides: .init(isOptional: header.isOptional, userDescription: header.header.description)
overrides: .init(userDescription: header.header.description)
)
return decl
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ struct TypeAssigner {
swiftComponent: asSwiftSafeName(originalName) + suffix,
jsonComponent: jsonReferenceComponentOverride ?? originalName
)
.asUsage.withOptional(try typeMatcher.isOptional(schema, components: components))
.asUsage.withOptional(try typeMatcher.isOptionalRoot(schema, components: components))
}

/// Returns a type name for a reusable component.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ struct TypeMatcher {
},
genericArrayHandler: { TypeName.arrayContainer.asUsage }
)?
.withOptional(isOptional(schema, components: components))
.withOptional(isOptionalRoot(schema, components: components))
}

/// Returns a Boolean value that indicates whether the schema
Expand Down Expand Up @@ -247,10 +247,29 @@ struct TypeMatcher {
/// - Throws: An error if there's an issue while checking the schema.
/// - Returns: `true` if the schema is optional, `false` otherwise.
func isOptional(_ schema: JSONSchema, components: OpenAPI.Components) throws -> Bool {
var cache = [JSONReference<JSONSchema>: Bool]()
return try isOptional(schema, components: components, cache: &cache)
}

/// Returns a Boolean value indicating whether the schema is optional.
/// - Parameters:
/// - schema: The schema to check.
/// - components: The OpenAPI components for looking up references.
/// - cache: Memoised optionality by reference.
/// - Throws: An error if there's an issue while checking the schema.
/// - Returns: `true` if the schema is optional, `false` otherwise.
func isOptional(_ schema: JSONSchema, components: OpenAPI.Components, cache: inout [JSONReference<JSONSchema>: Bool]) throws -> Bool {
if schema.nullable || !schema.required { return true }
guard case .reference(let ref, _) = schema.value else { return false }
let targetSchema = try components.lookup(ref)
return try isOptional(targetSchema, components: components)
switch schema.value {
case .null(_):
return true
case .reference(let ref, _):
return try isOptional(ref, components: components, cache: &cache)
case .one(of: let schemas, core: _):
return try schemas.contains(where: { try isOptional($0, components: components, cache: &cache) })
default:
return schema.nullable
}
}

/// Returns a Boolean value indicating whether the schema is optional.
Expand All @@ -260,15 +279,89 @@ struct TypeMatcher {
/// - Throws: An error if there's an issue while checking the schema.
/// - Returns: `true` if the schema is optional, `false` otherwise.
func isOptional(_ schema: UnresolvedSchema?, components: OpenAPI.Components) throws -> Bool {
var cache = [JSONReference<JSONSchema>: Bool]()
return try isOptional(schema, components: components, cache: &cache)
}

/// Returns a Boolean value indicating whether the schema is optional.
/// - Parameters:
/// - schema: The schema to check.
/// - components: The OpenAPI components for looking up references.
/// - cache: Memoised optionality by reference.
/// - Throws: An error if there's an issue while checking the schema.
/// - Returns: `true` if the schema is optional, `false` otherwise.
func isOptional(_ schema: UnresolvedSchema?, components: OpenAPI.Components, cache: inout [JSONReference<JSONSchema>: Bool]) throws -> Bool {
guard let schema else {
// A nil unresolved schema represents a non-optional fragment.
return false
}
switch schema {
case .a(let ref):
let targetSchema = try components.lookup(ref)
return try isOptional(targetSchema, components: components)
case .b(let schema): return try isOptional(schema, components: components)
return try isOptional(ref.jsonReference, components: components, cache: &cache)
case .b(let schema): return try isOptional(schema, components: components, cache: &cache)
}
}

/// Returns a Boolean value indicating whether the referenced schema is optional.
/// - Parameters:
/// - schema: The reference to check.
/// - components: The OpenAPI components for looking up references.
/// - Throws: An error if there's an issue while checking the schema.
/// - Returns: `true` if the schema is optional, `false` otherwise.
func isOptional(_ ref: JSONReference<JSONSchema>, components: OpenAPI.Components) throws -> Bool {
var cache = [JSONReference<JSONSchema>: Bool]()
return try isOptional(ref, components: components, cache: &cache)
}

/// Returns a Boolean value indicating whether the referenced schema is optional.
/// - Parameters:
/// - schema: The reference to check.
/// - components: The OpenAPI components for looking up references.
/// - cache: Memoised optionality by reference.
/// - Throws: An error if there's an issue while checking the schema.
/// - Returns: `true` if the schema is optional, `false` otherwise.
func isOptional(_ ref: JSONReference<JSONSchema>, components: OpenAPI.Components, cache: inout [JSONReference<JSONSchema>: Bool]) throws -> Bool {
if let result = cache[ref] {
return result
}
let targetSchema = try components.lookup(ref)
cache[ref] = false // Pre-cache to treat directly recursive types as non-nullable.
let result = try isOptional(targetSchema, components: components, cache: &cache)
cache[ref] = result
return result
}

/// Returns a Boolean value indicating whether the schema is optional at the root of any references.
/// - Parameters:
/// - schema: The reference to check.
/// - components: The OpenAPI components for looking up references.
/// - Throws: An error if there's an issue while checking the schema.
/// - Returns: `true` if the schema is an optional root, `false` otherwise.
func isOptionalRoot(_ schema: JSONSchema, components: OpenAPI.Components) throws -> Bool {
let directlyOptional = schema.nullable || !schema.required
switch schema.value {
case .null(_):
return true
case .reference(let ref, _):
let indirectlyOptional = try isOptional(ref, components: components)
return directlyOptional && !indirectlyOptional
default:
return directlyOptional
}
}

/// Returns a Boolean value indicating whether the schema admits only explicit null values.
/// - Parameters:
/// - schema: The schema to check.
/// - Returns: `true` if the schema admits only explicit null values, `false` otherwise.
func isNull(_ schema: JSONSchema) -> Bool {
switch schema.value {
case .null(_):
return true
case let .fragment(core):
return core.format.jsonType == .null
default:
return false
}
}

Expand All @@ -289,6 +382,7 @@ struct TypeMatcher {
private static func _tryMatchBuiltinNonRecursive(for schema: JSONSchema.Schema) -> TypeUsage? {
let typeName: TypeName
switch schema {
case .null(_): typeName = TypeName.valueContainer
case .boolean(_): typeName = .swift("Bool")
case .number(let core, _):
switch core.format {
Expand Down Expand Up @@ -331,7 +425,7 @@ struct TypeMatcher {
// arrays are already recursed-into by _tryMatchTypeRecursive
// so just return nil here
return nil
case .reference, .not, .all, .any, .one, .null:
case .reference, .not, .all, .any, .one:
// never built-in
return nil
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ extension FileTranslator {
func isSchemaSupported(_ schema: JSONSchema, referenceStack: inout ReferenceStack) throws -> IsSchemaSupportedResult
{
switch schema.value {
case .string, .integer, .number, .boolean,
case .null, .string, .integer, .number, .boolean,
// We mark any object as supported, even if it
// has unsupported properties.
// The code responsible for emitting an object is
Expand Down Expand Up @@ -173,7 +173,7 @@ extension FileTranslator {
schemas.filter(\.isReference),
referenceStack: &referenceStack
)
case .not, .null: return .unsupported(reason: .schemaType, schema: schema)
case .not: return .unsupported(reason: .schemaType, schema: schema)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ For example:

```yaml
MyOptionalString:
type: [string, null]
type: [string, 'null']
```

> The rule can be summarized as: `schema is optional := schema is nullable`, where being `nullable` is represented slightly differently between the two JSON Schema versions.
Expand Down Expand Up @@ -75,7 +75,7 @@ MyPerson:
name:
type: string
age:
type: [integer, null]
type: [integer, 'null']
required:
- name
- age # even though required, the nullability of the schema "wins"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,27 @@ class Test_TypeAssigner: Test_Core {
}
}

func testTypeNameForReferenceProperties() throws {
let parent = TypeName(swiftKeyPath: ["MyType"])
let components: OpenAPI.Components = .init(schemas: [
"SomeString": .string(),
"MaybeString": .one(of: [.reference(.component(named: "SomeString")), .null()]),
])
func assertTypeName(_ property: String, _ schema: JSONSchema, _ typeName: String, file: StaticString = #file, line: UInt = #line) throws {
let actual = try typeAssigner.typeUsage(
forObjectPropertyNamed: property,
withSchema: schema,
components: components,
inParent: parent
).fullyQualifiedSwiftName
XCTAssertEqual(typeName, actual, file: file, line: line)
}
try assertTypeName("someString", .reference(.component(named: "SomeString")), "Components.Schemas.SomeString")
try assertTypeName("maybeString", .reference(.component(named: "MaybeString")), "Components.Schemas.MaybeString")
try assertTypeName("optionalSomeString", .reference(.component(named: "SomeString"), required: false), "Components.Schemas.SomeString?")
try assertTypeName("optionalMaybeString", .reference(.component(named: "MaybeString"), required: false), "Components.Schemas.MaybeString")
}

func testContentSwiftName() throws {
let nameMaker = makeTranslator().typeAssigner.contentSwiftName
let cases: [(String, String)] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ final class Test_TypeMatcher: Test_Core {
}

static let optionalTestCases: [(JSONSchema, Bool)] = [
// Explicit null.
(.null(), true),

// A required string.
(.string, false), (.string(required: true, nullable: false), false),
Expand All @@ -227,10 +229,26 @@ final class Test_TypeMatcher: Test_Core {
// A reference pointing to a required schema.
(.reference(.component(named: "RequiredString")), false),
(.reference(.component(named: "NullableString")), true),

// Unknown type.
(.fragment(), false),
(.fragment(nullable: true), true),

// References.
(.reference(.component(named: "List")), true),
(.reference(.component(named: "Loop")), false),
]
func testOptionalSchemas() throws {
let components = OpenAPI.Components(schemas: [
"RequiredString": .string, "NullableString": .string(nullable: true),
// Singlely linked list where null is an empty list.
"List": .one(of: [
.null(),
.object(properties: ["next": .reference(.component(named: "List"),
required: true)])]),
// A non-empty circular linked list.
"Loop": .object(properties: ["next": .reference(.component(named: "Loop"),
required: true)]),
])
for (schema, expectedIsOptional) in Self.optionalTestCases {
let actualIsOptional = try typeMatcher.isOptional(schema, components: components)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -403,9 +403,9 @@ components:
type: object
properties:
foo:
type: [array, null]
type: [array, 'null']
items:
type: [string, null]
type: [string, 'null']
CodeError:
type: object
properties:
Expand Down
Loading