Skip to content

Commit

Permalink
Merge pull request #145 from mattpolzin/v2/142/schema-dereferencing-v…
Browse files Browse the repository at this point in the history
…s-resolution

allow dereferenced JSON schemas to still contain allOf components
  • Loading branch information
mattpolzin authored Sep 6, 2020
2 parents bbbbe5f + 4096e07 commit d7a08cb
Show file tree
Hide file tree
Showing 6 changed files with 275 additions and 118 deletions.
76 changes: 32 additions & 44 deletions Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext {
public typealias StringContext = JSONSchema.StringContext

case boolean(CoreContext<JSONTypeFormat.BooleanFormat>)
indirect case object(CoreContext<JSONTypeFormat.ObjectFormat>, ObjectContext)
indirect case array(CoreContext<JSONTypeFormat.ArrayFormat>, ArrayContext)
case number(CoreContext<JSONTypeFormat.NumberFormat>, NumericContext)
case integer(CoreContext<JSONTypeFormat.IntegerFormat>, IntegerContext)
case string(CoreContext<JSONTypeFormat.StringFormat>, StringContext)
indirect case object(CoreContext<JSONTypeFormat.ObjectFormat>, ObjectContext)
indirect case array(CoreContext<JSONTypeFormat.ArrayFormat>, ArrayContext)
indirect case all(of: [DereferencedJSONSchema], core: CoreContext<JSONTypeFormat.AnyFormat>)
indirect case one(of: [DereferencedJSONSchema], core: CoreContext<JSONTypeFormat.AnyFormat>)
indirect case any(of: [DereferencedJSONSchema], core: CoreContext<JSONTypeFormat.AnyFormat>)
indirect case not(DereferencedJSONSchema, core: CoreContext<JSONTypeFormat.AnyFormat>)
Expand Down Expand Up @@ -51,6 +52,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext {
return .integer(coreContext, integerContext)
case .string(let coreContext, let stringContext):
return .string(coreContext, stringContext)
case .all(of: let schemas, core: let coreContext):
return .all(of: schemas.map { $0.jsonSchema }, core: coreContext)
case .one(of: let schemas, core: let coreContext):
return .one(of: schemas.map { $0.jsonSchema }, core: coreContext)
case .any(of: let schemas, core: let coreContext):
Expand Down Expand Up @@ -102,32 +105,6 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext {

// See `JSONSchemaContext`
public var deprecated: Bool { jsonSchema.deprecated }

/// Returns a version of this `JSONSchema` that has the given discriminator.
public func with(discriminator: OpenAPI.Discriminator) -> DereferencedJSONSchema {
switch self {
case .boolean(let context):
return .boolean(context.with(discriminator: discriminator))
case .object(let contextA, let contextB):
return .object(contextA.with(discriminator: discriminator), contextB)
case .array(let contextA, let contextB):
return .array(contextA.with(discriminator: discriminator), contextB)
case .number(let context, let contextB):
return .number(context.with(discriminator: discriminator), contextB)
case .integer(let context, let contextB):
return .integer(context.with(discriminator: discriminator), contextB)
case .string(let context, let contextB):
return .string(context.with(discriminator: discriminator), contextB)
case .one(of: let schemas, core: let core):
return .one(of: schemas, core: core.optionalContext())
case .any(of: let schemas, core: let core):
return .any(of: schemas, core: core.optionalContext())
case .not(let schema, core: let core):
return .not(schema, core: core.optionalContext())
case .fragment(let context):
return .fragment(context.with(discriminator: discriminator))
}
}
}

extension DereferencedJSONSchema {
Expand Down Expand Up @@ -191,6 +168,18 @@ extension DereferencedJSONSchema {
_uniqueItems = arrayContext._uniqueItems
}

internal init(
items: DereferencedJSONSchema? = nil,
maxItems: Int? = nil,
minItems: Int? = nil,
uniqueItems: Bool? = nil
) {
self.items = items
self.maxItems = maxItems
self._minItems = minItems
self._uniqueItems = uniqueItems
}

internal var jsonSchemaArrayContext: JSONSchema.ArrayContext {
.init(
items: items.map { $0.jsonSchema },
Expand Down Expand Up @@ -272,6 +261,18 @@ extension DereferencedJSONSchema {
}
}

internal init(
properties: [String: DereferencedJSONSchema],
additionalProperties: Either<Bool, DereferencedJSONSchema>? = nil,
maxProperties: Int? = nil,
minProperties: Int? = nil
) {
self.properties = properties
self.additionalProperties = additionalProperties
self.maxProperties = maxProperties
self._minProperties = minProperties
}

internal var jsonSchemaObjectContext: JSONSchema.ObjectContext {
let underlyingAdditionalProperties: Either<Bool, JSONSchema>?
switch additionalProperties {
Expand All @@ -298,11 +299,6 @@ extension JSONSchema: LocallyDereferenceable {
/// Returns a dereferenced schema object if all references in
/// this schema object can be found in the Components Object.
///
/// Dereferencing a `JSONSchema` currently relies on resolving
/// `all(of:)` schemas (thus removing all `JSONSchemaFragments`).
/// All fragments are combined into a new schema if possible and an error
/// is thrown if no valid schema can be created.
///
/// - Important: Local dereferencing will `throw` if any
/// `JSONReferences` point to other files or to
/// locations within the same file other than the
Expand Down Expand Up @@ -335,11 +331,9 @@ extension JSONSchema: LocallyDereferenceable {
return .integer(coreContext, integerContext)
case .string(let coreContext, let stringContext):
return .string(coreContext, stringContext)
case .all(of: let fragments, core: let coreContext):
// TODO: use the core context of the `allOf` schema somehow here
// on the resolved schema.
let resolvedFragments = try fragments.resolved(against: components)
return coreContext.discriminator.map { resolvedFragments.with(discriminator: $0) } ?? resolvedFragments
case .all(of: let jsonSchemas, core: let coreContext):
let schemas = try jsonSchemas.map { try $0.dereferenced(in: components) }
return .all(of: schemas, core: coreContext)
case .one(of: let jsonSchemas, core: let coreContext):
let schemas = try jsonSchemas.map { try $0.dereferenced(in: components) }
return .one(of: schemas, core: coreContext)
Expand All @@ -358,12 +352,6 @@ extension JSONSchema: LocallyDereferenceable {
///
/// To create a dereferenced schema object from a schema object
/// that does have references, use `dereferenced(in:)`.
///
/// - Important: Dereferencing an `all(of:)` schema will
/// also attempt to resolve it and fail if it cannot. Resolving
/// an `all(of:)` schema involves combining the fragments
/// of the `all(of:)` into one schema and failing if no
/// valid schema can be created.
public func dereferenced() -> DereferencedJSONSchema? {
return try? dereferenced(in: .noComponents)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
//
// JSONSchema+Resolving.swift
// JSONSchema+Combining.swift
//
//
// Created by Mathew Polzin on 8/1/20.
//

extension Array where Element == JSONSchema {
/// An array of schema fragments can be resolved into a
/// An array of schema fragments can be combined into a
/// single `DereferencedJSONSchema` if all references can
/// be looked up locally and none of the fragments conflict.
///
/// Resolving fragments will both remove references and attempt
/// Combining fragments will both remove references and attempt
/// to reject any results that would represent impossible schemas
/// -- that is, schemas that cannot be satisfied and could not ever
/// be used to validate anything (guaranteed validation failure).
public func resolved(against components: OpenAPI.Components) throws -> DereferencedJSONSchema {
var resolver = FragmentResolver(components: components)
public func combined(resolvingAgainst components: OpenAPI.Components) throws -> DereferencedJSONSchema {
var resolver = FragmentCombiner(components: components)
try resolver.combine(self)
return try resolver.dereferencedSchema()
}
Expand Down Expand Up @@ -71,7 +71,7 @@ internal enum _JSONSchemaResolutionError: CustomStringConvertible, Equatable {
var description: String {
switch self {
case .unsupported(because: let reason):
return "The given `all(of:)` schema does not yet support resolving in OpenAPIKit because \(reason)."
return "The given schema does not yet support combining in OpenAPIKit because \(reason)."
case .typeConflict(original: let original, new: let new):
return "Found conflicting schema types. A schema cannot be both \(original.rawValue) and \(new.rawValue)."
case .formatConflict(original: let original, new: let new):
Expand All @@ -85,7 +85,7 @@ internal enum _JSONSchemaResolutionError: CustomStringConvertible, Equatable {
}
}

/// The FragmentResolver takes any number of fragments and determines if they can be
/// The FragmentCombiner takes any number of fragments and determines if they can be
/// meaningfully combined.
///
/// Conflicts will be determined as fragments are added and when you ask for
Expand All @@ -94,7 +94,7 @@ internal enum _JSONSchemaResolutionError: CustomStringConvertible, Equatable {
///
/// Current Limitations (will throw `.unsupported` for these reasons):
/// - Does not handle inversion via `not` or combination via `any`, `one`, `all`.
internal struct FragmentResolver {
internal struct FragmentCombiner {
private let components: OpenAPI.Components
private var combinedFragment: JSONSchema?

Expand Down Expand Up @@ -135,8 +135,13 @@ internal struct FragmentResolver {
}

switch (lessSpecializedFragment, equallyOrMoreSpecializedFragment) {
case (.all(let schemas, core: let core), let other), (let other, .all(let schemas, core: let core)):
// tease apart one allOf if there is one and continue from there.
try self.combine(schemas + [.fragment(core), other])

case (_, .reference(let reference)), (.reference(let reference), _):
try combine(components.lookup(reference))

case (.fragment(let leftCoreContext), .fragment(let rightCoreContext)):
self.combinedFragment = .fragment(try leftCoreContext.combined(with: rightCoreContext))
case (.fragment(let leftCoreContext), .boolean(let rightCoreContext)):
Expand All @@ -163,8 +168,9 @@ internal struct FragmentResolver {
self.combinedFragment = .array(try leftCoreContext.combined(with: rightCoreContext), try leftArrayContext.combined(with: rightArrayContext))
case (.object(let leftCoreContext, let leftObjectContext), .object(let rightCoreContext, let rightObjectContext)):
self.combinedFragment = .object(try leftCoreContext.combined(with: rightCoreContext), try leftObjectContext.combined(with: rightObjectContext, resolvingIn: components))
case (_, .any), (.any, _), (_, .all), (.all, _), (_, .not), (.not, _), (_, .one), (.one, _):
throw JSONSchemaResolutionError(.unsupported(because: "not, any(of:), all(of:), and one(of:) are not yet supported for schema resolution"))

case (_, .any), (.any, _), (_, .not), (.not, _), (_, .one), (.one, _):
throw JSONSchemaResolutionError(.unsupported(because: "not, any(of:), and one(of:) are not yet supported for schema resolution"))
case (.boolean, _),
(.integer, _),
(.number, _),
Expand Down Expand Up @@ -214,10 +220,12 @@ internal struct FragmentResolver {
jsonSchema = .array(try coreContext.validatedContext(), try arrayContext.validatedContext())
case .object(let coreContext, let objectContext):
jsonSchema = .object(try coreContext.validatedContext(), try objectContext.validatedContext())
case .any, .all, .not, .one:
case .all(of: let schemas, core: let coreContext):
jsonSchema = try .all(of: schemas, core: coreContext.validatedContext())
case .any, .not, .one:
throw JSONSchemaResolutionError(.unsupported(because: "not, any(of:), all(of:), and one(of:) are not yet supported for schema resolution"))
}
return try jsonSchema.dereferenced(in: components)
return try jsonSchema.simplified(given: components)
}
}

Expand Down Expand Up @@ -518,9 +526,9 @@ extension JSONSchema.ObjectContext {
internal func combine(properties left: [String: JSONSchema], with right: [String: JSONSchema], resolvingIn components: OpenAPI.Components) throws -> [String: JSONSchema] {
var combined = left
try combined.merge(right) { (left, right) throws -> JSONSchema in
var resolve = FragmentResolver(components: components)
try resolve.combine([left, right])
return try resolve.dereferencedSchema().jsonSchema
var resolver = FragmentCombiner(components: components)
try resolver.combine([left, right])
return try resolver.dereferencedSchema().jsonSchema
}
return combined
}
Expand Down
140 changes: 140 additions & 0 deletions Sources/OpenAPIKit/Schema Object/SimplifiedJSONSchema.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//
// SimplifiedJSONSchema.swift
//
//
// Created by Mathew Polzin on 9/4/20.
//

import Foundation

extension JSONSchema {
/// Get a simplified `DereferencedJSONSchema`.
///
/// A fully simplified JSON Schema is both dereferenced and also
/// reduced to a more normal form where possible.
///
/// As an example, many compound schemas can be simplified.
///
/// {
/// "allOf": [
/// { "type": "object", "description": "Hello World" },
/// {
/// "properties": [
/// "name": { "type": "string" }
/// ]
/// }
/// ]
/// }
///
/// simplifies to ->
///
/// {
/// "type": "object",
/// "description": "Hello World",
/// "properties": [
/// "name": { "type": "string" }
/// ]
/// }
///
/// You can create simplified schemas from the `DereferencedJSONSchema`
/// type with the `simplified()` method or you can create simplified schemas from
/// the `JSONSchema` type with the `simplified(given:)` method (which
/// combines dereferencing and resolving by taking the `OpenAPI.Components` as
/// input).
public func simplified(given components: OpenAPI.Components) throws -> DereferencedJSONSchema {
return try self.dereferenced(in: components).simplified()
}
}

extension DereferencedJSONSchema {
/// Get a simplified `DereferencedJSONSchema`.
///
/// A fully simplified JSON Schema is both dereferenced and also
/// reduced to a more normal form where possible.
///
/// As an example, many compound schemas can be simplified.
///
/// {
/// "allOf": [
/// { "type": "object", "description": "Hello World" },
/// {
/// "properties": [
/// "name": { "type": "string" }
/// ]
/// }
/// ]
/// }
///
/// simplifies to ->
///
/// {
/// "type": "object",
/// "description": "Hello World",
/// "properties": [
/// "name": { "type": "string" }
/// ]
/// }
///
/// You can create simplified schemas from the `DereferencedJSONSchema`
/// type with the `simplified()` method or you can create simplified schemas from
/// the `JSONSchema` type with the `simplified(given:)` method (which
/// combines dereferencing and resolving by taking the `OpenAPI.Components` as
/// input).
public func simplified() throws -> DereferencedJSONSchema {
let dereferencedSchema: DereferencedJSONSchema
switch self {
case .all:
var resolver = FragmentCombiner(components: .noComponents)
try resolver.combine(self.jsonSchema)
dereferencedSchema = try resolver.dereferencedSchema()

// we don't currently have any schema resolution steps other than
// combining allOf schemas. We do need to dig into any other compound
// schemas to attempt to resolve them, though.

case .object(let core, let object):
let additionalProperties: Either<Bool, DereferencedJSONSchema>? = try object.additionalProperties.map {
switch $0 {
case .a(let bool):
return .a(bool)
case .b(let schema):
return .b(try schema.simplified())
}
}
dereferencedSchema = .object(
core,
.init(
properties: try object.properties.mapValues { try $0.simplified() },
additionalProperties: additionalProperties,
maxProperties: object.maxProperties,
minProperties: object._minProperties
)
)

case .array(let core, let array):
dereferencedSchema = .array(
core,
.init(
items: try array.items.map { try $0.simplified() },
maxItems: array.maxItems,
minItems: array._minItems,
uniqueItems: array._uniqueItems
)
)

case .any(of: let schemas, core: let core):
dereferencedSchema = .any(of: try schemas.map { try $0.simplified() }, core: core)

case .one(of: let schemas, core: let core):
dereferencedSchema = .one(of: try schemas.map { try $0.simplified() }, core: core)

case .not(let schema, core: let core):
dereferencedSchema = .not(try schema.simplified(), core: core)

default:
dereferencedSchema = self
}

return dereferencedSchema
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,7 @@ components:
)

XCTAssertEqual(
resolvedDoc.endpoints[0].requestBody?
.content[.json]?.schema?.jsonSchema,
try resolvedDoc.endpoints[0].requestBody?.content[.json]?.schema?.simplified().jsonSchema,
JSONSchema.one(
of: [
catSchema,
Expand Down
Loading

0 comments on commit d7a08cb

Please sign in to comment.