diff --git a/JSONAPI.playground/Pages/OpenAPI Documentation.xcplaygroundpage/Contents.swift b/JSONAPI.playground/Pages/OpenAPI Documentation.xcplaygroundpage/Contents.swift index c1b6cc9..e9c018a 100644 --- a/JSONAPI.playground/Pages/OpenAPI Documentation.xcplaygroundpage/Contents.swift +++ b/JSONAPI.playground/Pages/OpenAPI Documentation.xcplaygroundpage/Contents.swift @@ -10,4 +10,14 @@ encoder.outputFormatting = .prettyPrinted let personSchemaData = try? encoder.encode(Person.openAPINode()) +print("Person Schema") +print("====") print(personSchemaData.map { String(data: $0, encoding: .utf8)! } ?? "Schema Construction Failed") +print("====") + +let dogDocumentSchemaData = try? encoder.encode(SingleDogDocument.openAPINodeWithExample()) + +print("Dog Document Schema") +print("====") +print(dogDocumentSchemaData.map { String(data: $0, encoding: .utf8)! } ?? "Schema Construction Failed") +print("====") diff --git a/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift b/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift index bd94b45..25b0235 100644 --- a/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift +++ b/JSONAPI.playground/Pages/Usage.xcplaygroundpage/Contents.swift @@ -11,8 +11,6 @@ Please enjoy these examples, but allow me the forced casting and the lack of err // MARK: - Create a request or response body with one Dog in it let dogFromCode = try! Dog(name: "Buddy", owner: nil) -typealias SingleDogDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> - let singleDogDocument = SingleDogDocument(apiDescription: .none, body: .init(entity: dogFromCode), includes: .none, meta: .none, links: .none) let singleDogData = try! JSONEncoder().encode(singleDogDocument) @@ -34,8 +32,6 @@ let dogs = try! [Dog(name: "Buddy", owner: personIds[0]), Dog(name: "Joy", owner let houses = [House(attributes: .none, relationships: .none, meta: .none, links: .none), House(attributes: .none, relationships: .none, meta: .none, links: .none)] let people = try! [Person(id: personIds[0], name: ["Gary", "Doe"], favoriteColor: "Orange-Red", friends: [], dogs: [dogs[0], dogs[1]], home: houses[0]), Person(id: personIds[1], name: ["Elise", "Joy"], favoriteColor: "Red", friends: [], dogs: [dogs[2]], home: houses[1])] -typealias BatchPeopleDocument = JSONAPI.Document, NoMetadata, NoLinks, Include2, NoAPIDescription, UnknownJSONAPIError> - let includes = dogs.map { BatchPeopleDocument.Include($0) } + houses.map { BatchPeopleDocument.Include($0) } let batchPeopleDocument = BatchPeopleDocument(apiDescription: .none, body: .init(entities: people), includes: .init(values: includes), meta: .none, links: .none) let batchPeopleData = try! JSONEncoder().encode(batchPeopleDocument) diff --git a/JSONAPI.playground/Sources/Entities.swift b/JSONAPI.playground/Sources/Entities.swift index 48559cf..c340862 100644 --- a/JSONAPI.playground/Sources/Entities.swift +++ b/JSONAPI.playground/Sources/Entities.swift @@ -139,4 +139,6 @@ public enum HouseDescription: EntityDescription { public typealias House = ExampleEntity +public typealias SingleDogDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> +public typealias BatchPeopleDocument = JSONAPI.Document, NoMetadata, NoLinks, Include2, NoAPIDescription, UnknownJSONAPIError> diff --git a/JSONAPI.playground/Sources/OpenAPISupport.swift b/JSONAPI.playground/Sources/OpenAPISupport.swift index 5b7e895..ac267dd 100644 --- a/JSONAPI.playground/Sources/OpenAPISupport.swift +++ b/JSONAPI.playground/Sources/OpenAPISupport.swift @@ -2,6 +2,8 @@ import Foundation import JSONAPI import JSONAPITesting // for the convenience of literal initialization import JSONAPIOpenAPI +import SwiftCheck +import JSONAPIArbitrary extension PersonDescription.Attributes: Sampleable { public static var sample: PersonDescription.Attributes { @@ -14,3 +16,41 @@ extension PersonDescription.Relationships: Sampleable { return .init(friends: ["1", "2"], dogs: ["2"], home: "1") } } + +extension DogDescription.Attributes: Arbitrary, Sampleable { + public static var arbitrary: Gen { + return Gen.compose { c in + return DogDescription.Attributes(name: c.generate()) + } + } + + public static var sample: DogDescription.Attributes { + return DogDescription.Attributes.arbitrary.generate + } +} + +extension DogDescription.Relationships: Arbitrary, Sampleable { + public static var arbitrary: Gen { + return Gen.compose { c in + return DogDescription.Relationships(owner: c.generate()) + } + } + + public static var sample: DogDescription.Relationships { + return DogDescription.Relationships.arbitrary.generate + } +} + +extension Document: Sampleable where PrimaryResourceBody: Arbitrary, IncludeType: Arbitrary, MetaType: Arbitrary, LinksType: Arbitrary, Error: Arbitrary, APIDescription: Arbitrary { + public static var sample: Document { + return Document.arbitrary.generate + } + + public static var successSample: Document? { + return Document.arbitraryData.generate + } + + public static var failureSample: Document? { + return Document.arbitraryErrors.generate + } +} diff --git a/JSONAPI.playground/contents.xcplayground b/JSONAPI.playground/contents.xcplayground index a1ec109..e240eff 100644 --- a/JSONAPI.playground/contents.xcplayground +++ b/JSONAPI.playground/contents.xcplayground @@ -5,6 +5,5 @@ - \ No newline at end of file diff --git a/README.md b/README.md index ab22a21..6ffe3e1 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ See the JSON API Spec here: https://jsonapi.org/format/ - [Relationship Object](#relationship-object) - [Links Object](#links-object) - [Misc](#misc) - - [JSONAPI+Testing](#jsonapitesting) + - [Testing](#testing) - [Entity Validator](#entity-validator) - [Potential Improvements](#potential-improvements) - [Usage](#usage) @@ -100,7 +100,7 @@ Note that Playground support for importing non-system Frameworks is still a bit - `data` - [x] Encoding/Decoding - [x] Arbitrary - - [ ] OpenAPI + - [x] OpenAPI - `included` - [x] Encoding/Decoding - [x] Arbitrary @@ -175,7 +175,7 @@ Note that Playground support for importing non-system Frameworks is still a bit - [ ] Support sparse fieldsets. At the moment, not sure what this support will look like. A client can likely just define a new model to represent a sparse population of another model in a very specific use case. On the server side, it becomes much more appealing to be able to support arbitrary combinations of omitted fields. - [ ] Create more descriptive errors that are easier to use for troubleshooting. -### JSONAPI+Testing +### Testing #### Entity Validator - [x] Disallow optional array in `Attribute` (should be empty array, not `null`). - [x] Only allow `TransformedAttribute` and its derivatives as stored properties within `Attributes` struct. Computed properties can still be any type because they do not get encoded or decoded. diff --git a/Sources/JSONAPIArbitrary/Error+Arbitrary.swift b/Sources/JSONAPIArbitrary/Error+Arbitrary.swift new file mode 100644 index 0000000..0369c69 --- /dev/null +++ b/Sources/JSONAPIArbitrary/Error+Arbitrary.swift @@ -0,0 +1,15 @@ +// +// Error+Arbitrary.swift +// JSONAPIArbitrary +// +// Created by Mathew Polzin on 1/21/19. +// + +import SwiftCheck +import JSONAPI + +extension UnknownJSONAPIError: Arbitrary { + public static var arbitrary: Gen { + return Gen.pure(.unknownError) + } +} diff --git a/Sources/JSONAPIOpenAPI/JSONAPIOpenAPITypes.swift b/Sources/JSONAPIOpenAPI/JSONAPITypes+OpenAPI.swift similarity index 72% rename from Sources/JSONAPIOpenAPI/JSONAPIOpenAPITypes.swift rename to Sources/JSONAPIOpenAPI/JSONAPITypes+OpenAPI.swift index 6afd065..619e296 100644 --- a/Sources/JSONAPIOpenAPI/JSONAPIOpenAPITypes.swift +++ b/Sources/JSONAPIOpenAPI/JSONAPITypes+OpenAPI.swift @@ -72,13 +72,14 @@ extension TransformedAttribute: OpenAPINodeType where RawValue: OpenAPINodeType } extension RelationshipType { - static func relationshipNode(nullable: Bool) -> JSONNode { + static func relationshipNode(nullable: Bool, jsonType: String) -> JSONNode { let propertiesDict: [String: JSONNode] = [ "id": .string(.init(format: .generic, required: true), .init()), "type": .string(.init(format: .generic, - required: true), + required: true, + allowedValues: [.init(jsonType)]), .init()) ] @@ -90,20 +91,24 @@ extension RelationshipType { } extension ToOneRelationship: OpenAPINodeType { - // TODO: const for json `type` + // NOTE: const for json `type` not supported by OpenAPI 3.0 + // Will use "enum" with one possible value for now. + // TODO: metadata & links static public func openAPINode() throws -> JSONNode { let nullable = Identifiable.self is _Optional.Type return .object(.init(format: .generic, required: true), .init(properties: [ - "data": ToOneRelationship.relationshipNode(nullable: nullable) + "data": ToOneRelationship.relationshipNode(nullable: nullable, jsonType: Identifiable.jsonType) ])) } } extension ToManyRelationship: OpenAPINodeType { - // TODO: const for json `type` + // NOTE: const for json `type` not supported by OpenAPI 3.0 + // Will use "enum" with one possible value for now. + // TODO: metadata & links static public func openAPINode() throws -> JSONNode { return .object(.init(format: .generic, @@ -111,13 +116,16 @@ extension ToManyRelationship: OpenAPINodeType { .init(properties: [ "data": .array(.init(format: .generic, required: true), - .init(items: ToManyRelationship.relationshipNode(nullable: false))) + .init(items: ToManyRelationship.relationshipNode(nullable: false, jsonType: Relatable.jsonType))) ])) } } extension Entity: OpenAPINodeType where Description.Attributes: Sampleable, Description.Relationships: Sampleable { public static func openAPINode() throws -> JSONNode { + // NOTE: const for json `type` not supported by OpenAPI 3.0 + // Will use "enum" with one possible value for now. + // TODO: metadata, links let idNode = JSONNode.string(.init(format: .generic, @@ -126,7 +134,8 @@ extension Entity: OpenAPINodeType where Description.Attributes: Sampleable, Desc let idProperty = ("id", idNode) let typeNode = JSONNode.string(.init(format: .generic, - required: true), + required: true, + allowedValues: [.init(Entity.jsonType)]), .init()) let typeProperty = ("type", typeNode) @@ -147,7 +156,40 @@ extension Entity: OpenAPINodeType where Description.Attributes: Sampleable, Desc typeProperty, attributesProperty, relationshipsProperty - ].compactMap { $0 }) { _, value in value } + ].compactMap { $0 }) { _, value in value } + + return .object(.init(format: .generic, + required: true), + .init(properties: propertiesDict)) + } +} + +extension SingleResourceBody: OpenAPINodeType where Entity: OpenAPINodeType { + public static func openAPINode() throws -> JSONNode { + return try Entity.openAPINode() + } +} + +extension ManyResourceBody: OpenAPINodeType where Entity: OpenAPINodeType { + public static func openAPINode() throws -> JSONNode { + return .array(.init(format: .generic, + required: true), + .init(items: try Entity.openAPINode())) + } +} + +extension Document: OpenAPINodeType where PrimaryResourceBody: OpenAPINodeType { + public static func openAPINode() throws -> JSONNode { + // TODO: metadata, links, api description, includes, errors + // TODO: represent data and errors as the two distinct possible outcomes + + let primaryDataNode: JSONNode? = try PrimaryResourceBody.openAPINode() + + let primaryDataProperty = primaryDataNode.map { ("data", $0) } + + let propertiesDict = Dictionary([ + primaryDataProperty + ].compactMap { $0 }) { _, value in value } return .object(.init(format: .generic, required: true), diff --git a/Sources/JSONAPIOpenAPI/OpenAPITypes+Codable.swift b/Sources/JSONAPIOpenAPI/OpenAPITypes+Codable.swift index 7b04957..fa6d93b 100644 --- a/Sources/JSONAPIOpenAPI/OpenAPITypes+Codable.swift +++ b/Sources/JSONAPIOpenAPI/OpenAPITypes+Codable.swift @@ -12,6 +12,8 @@ extension JSONNode.Context: Encodable { case format case allowedValues = "enum" case nullable + case example +// case constantValue = "const" } public func encode(to encoder: Encoder) throws { @@ -27,7 +29,15 @@ extension JSONNode.Context: Encodable { try container.encode(allowedValues, forKey: .allowedValues) } +// if constantValue != nil { +// try container.encode(constantValue, forKey: .constantValue) +// } + try container.encode(nullable, forKey: .nullable) + + if example != nil { + try container.encode(example, forKey: .example) + } } } @@ -110,7 +120,7 @@ extension JSONNode.ArrayContext: Encodable { } } -extension JSONNode.ObjectContext : Encodable{ +extension JSONNode.ObjectContext : Encodable { private enum CodingKeys: String, CodingKey { case maxProperties case minProperties @@ -132,13 +142,9 @@ extension JSONNode.ObjectContext : Encodable{ try container.encode(additionalProperties, forKey: .additionalProperties) } - let required = properties.filter { (name, node) in - node.required - }.keys + try container.encode(requiredProperties, forKey: .required) - try container.encode(Array(required), forKey: .required) - - try container.encode(max(minProperties, required.count), forKey: .minProperties) + try container.encode(minProperties, forKey: .minProperties) } } diff --git a/Sources/JSONAPIOpenAPI/OpenAPITypes.swift b/Sources/JSONAPIOpenAPI/OpenAPITypes.swift index 79bf009..2a65d9e 100644 --- a/Sources/JSONAPIOpenAPI/OpenAPITypes.swift +++ b/Sources/JSONAPIOpenAPI/OpenAPITypes.swift @@ -16,6 +16,12 @@ public protocol OpenAPINodeType { static func openAPINode() throws -> JSONNode } +extension OpenAPINodeType where Self: Sampleable, Self: Encodable { + public static func openAPINodeWithExample() throws -> JSONNode { + return try openAPINode().with(example: Self.successSample ?? Self.sample) + } +} + /// Anything conforming to `RawOpenAPINodeType` can provide an /// OpenAPI schema representing itself. This second protocol is /// necessary so that one type can conditionally provide a @@ -249,6 +255,10 @@ public enum JSONNode: Equatable { public let required: Bool public let nullable: Bool + // NOTE: "const" is supported by the newest JSON Schema spec but not + // yet by OpenAPI. Instead, will use "enum" with one possible value for now. +// public let constantValue: Format.SwiftType? + /// The OpenAPI spec calls this "enum" /// If not specified, it is assumed that any /// value of the given format is allowed. @@ -261,14 +271,26 @@ public enum JSONNode: Equatable { /// into an allowed value. public let allowedValues: [AnyCodable]? + // I wanted example to be AnyCodable, but alas that causes + // runtime problems when encoding in a very strange way. + // For now, a String (which is OK by the OpenAPI spec) will + // have to do. + public let example: String? + public init(format: Format, required: Bool, nullable: Bool = false, - allowedValues: [AnyCodable]? = nil) { +// constantValue: Format.SwiftType? = nil, + allowedValues: [AnyCodable]? = nil, + example: AnyCodable? = nil) { self.format = format self.required = required self.nullable = nullable +// self.constantValue = constantValue self.allowedValues = allowedValues + self.example = example + .flatMap { try? JSONEncoder().encode($0)} + .flatMap { String(data: $0, encoding: .utf8) } } /// Return the optional version of this Context @@ -276,6 +298,7 @@ public enum JSONNode: Equatable { return .init(format: format, required: false, nullable: nullable, +// constantValue: constantValue, allowedValues: allowedValues) } @@ -284,6 +307,7 @@ public enum JSONNode: Equatable { return .init(format: format, required: true, nullable: nullable, +// constantValue: constantValue, allowedValues: allowedValues) } @@ -292,16 +316,28 @@ public enum JSONNode: Equatable { return .init(format: format, required: required, nullable: true, +// constantValue: constantValue, allowedValues: allowedValues) } /// Return this context with the given list of possible values - public func with(allowedValues: [AnyCodable]?) -> Context { + public func with(allowedValues: [AnyCodable]) -> Context { return .init(format: format, required: required, nullable: nullable, +// constantValue: constantValue, allowedValues: allowedValues) } + + /// Return this context with the given example + public func with(example: AnyCodable) -> Context { + return .init(format: format, + required: required, + nullable: nullable, +// constantValue: constantValue, + allowedValues: allowedValues, + example: example) + } } public struct NumericContext: Equatable { @@ -371,7 +407,7 @@ public enum JSONNode: Equatable { public struct ObjectContext: Equatable { public let maxProperties: Int? - public let minProperties: Int + let _minProperties: Int public let properties: [String: JSONNode] public let additionalProperties: [String: JSONNode]? @@ -379,8 +415,16 @@ public enum JSONNode: Equatable { // NOTE that an object's required properties // array is determined by looking at its properties' // required Bool. - public let required: [String] */ + public var requiredProperties: [String] { + return Array(properties.filter { (name, node) in + node.required + }.keys) + } + + public var minProperties: Int { + return max(_minProperties, requiredProperties.count) + } public init(properties: [String: JSONNode], additionalProperties: [String: JSONNode]? = nil, @@ -389,7 +433,7 @@ public enum JSONNode: Equatable { self.properties = properties self.additionalProperties = additionalProperties self.maxProperties = maxProperties - self.minProperties = minProperties + self._minProperties = minProperties } } @@ -505,8 +549,35 @@ public enum JSONNode: Equatable { return self } } + + public func with(example codableExample: T) throws -> JSONNode { + let example: AnyCodable + if let goodToGo = codableExample as? AnyCodable { + example = goodToGo + } else { + example = AnyCodable(try JSONSerialization.jsonObject(with: JSONEncoder().encode(codableExample), options: [])) + } + + switch self { + case .boolean(let context): + return .boolean(context.with(example: example)) + case .object(let contextA, let contextB): + return .object(contextA.with(example: example), contextB) + case .array(let contextA, let contextB): + return .array(contextA.with(example: example), contextB) + case .number(let context, let contextB): + return .number(context.with(example: example), contextB) + case .integer(let context, let contextB): + return .integer(context.with(example: example), contextB) + case .string(let context, let contextB): + return .string(context.with(example: example), contextB) + case .allOf, .oneOf, .anyOf, .not: + return self + } + } } public enum OpenAPICodableError: Swift.Error { case allCasesArrayNotCodable + case exampleNotCodable } diff --git a/Sources/JSONAPIOpenAPI/Optional+ZipWith.swift b/Sources/JSONAPIOpenAPI/Optional+ZipWith.swift index cbe113e..b04b7c2 100644 --- a/Sources/JSONAPIOpenAPI/Optional+ZipWith.swift +++ b/Sources/JSONAPIOpenAPI/Optional+ZipWith.swift @@ -5,6 +5,9 @@ // Created by Mathew Polzin on 1/19/19. // +/// Zip two optionals together with the given operation performed on +/// the unwrapped contents. If either optional is nil, the zip +/// yields nil. func zip(_ left: X?, _ right: Y?, with fn: (X, Y) -> Z) -> Z? { return left.flatMap { lft in right.map { rght in fn(lft, rght) }} } diff --git a/Sources/JSONAPIOpenAPI/Sampleable.swift b/Sources/JSONAPIOpenAPI/Sampleable.swift index 328080e..c730791 100644 --- a/Sources/JSONAPIOpenAPI/Sampleable.swift +++ b/Sources/JSONAPIOpenAPI/Sampleable.swift @@ -15,6 +15,20 @@ public protocol Sampleable { /// same value every time, or it can be an arbitrarily random /// value each time. static var sample: Self { get } + + /// Get an example of success, if that is meaningful and + /// available. If not, will be nil. + static var successSample: Self? { get } + + /// Get an example of failure, if that is meaningful and + /// available. If not, will be nil. + static var failureSample: Self? { get } +} + +public extension Sampleable { + public static var successSample: Self? { return nil } + + public static var failureSample: Self? { return nil } } extension Sampleable { diff --git a/Sources/JSONAPIOpenAPI/SwiftPrimitiveTypes.swift b/Sources/JSONAPIOpenAPI/SwiftPrimitiveTypes+OpenAPI.swift similarity index 100% rename from Sources/JSONAPIOpenAPI/SwiftPrimitiveTypes.swift rename to Sources/JSONAPIOpenAPI/SwiftPrimitiveTypes+OpenAPI.swift diff --git a/Tests/JSONAPIOpenAPITests/JSONAPIAttributeOpenAPITests.swift b/Tests/JSONAPIOpenAPITests/JSONAPIAttributeOpenAPITests.swift index 726f99a..642b95e 100644 --- a/Tests/JSONAPIOpenAPITests/JSONAPIAttributeOpenAPITests.swift +++ b/Tests/JSONAPIOpenAPITests/JSONAPIAttributeOpenAPITests.swift @@ -429,7 +429,7 @@ extension JSONAPIAttributeOpenAPITests { extension JSONAPIAttributeOpenAPITests { func test_EnumAttribute() { let node = try! Attribute.rawOpenAPINode() - print(EnumAttribute.allCases) + XCTAssertTrue(node.required) XCTAssertEqual(node.jsonTypeFormat, .string(.generic)) diff --git a/Tests/JSONAPIOpenAPITests/JSONAPIDocumentOpenAPITests.swift b/Tests/JSONAPIOpenAPITests/JSONAPIDocumentOpenAPITests.swift new file mode 100644 index 0000000..f709f17 --- /dev/null +++ b/Tests/JSONAPIOpenAPITests/JSONAPIDocumentOpenAPITests.swift @@ -0,0 +1,42 @@ +// +// JSONAPIDocumentOpenAPITests.swift +// JSONAPIOpenAPITests +// +// Created by Mathew Polzin on 1/21/19. +// + +import XCTest +import JSONAPI +import JSONAPIOpenAPI + +class JSONAPIDocumentOpenAPITests: XCTestCase { + func test_SingleResourceDocument() { + let node = try! SingleEntityDocument.openAPINode() + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + print(String(data: try! encoder.encode(node), encoding: .utf8)!) + } +} + +// MARK: - Test Types +extension JSONAPIDocumentOpenAPITests { + enum TestEntityDescription: EntityDescription { + static var jsonType: String { return "test" } + + struct Attributes: JSONAPI.Attributes, Sampleable { + let name: Attribute + + static var sample: Attributes { + return .init(name: "hello world") + } + } + + typealias Relationships = NoRelationships + } + + typealias TestEntity = BasicEntity + + typealias SingleEntityDocument = Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> +} diff --git a/Tests/JSONAPIOpenAPITests/JSONAPIEntityOpenAPITests.swift b/Tests/JSONAPIOpenAPITests/JSONAPIEntityOpenAPITests.swift index 298100c..abaf9d1 100644 --- a/Tests/JSONAPIOpenAPITests/JSONAPIEntityOpenAPITests.swift +++ b/Tests/JSONAPIOpenAPITests/JSONAPIEntityOpenAPITests.swift @@ -8,28 +8,240 @@ import XCTest import JSONAPI import JSONAPIOpenAPI +import AnyCodable class JSONAPIEntityOpenAPITests: XCTestCase { func test_EmptyEntity() { let node = try! TestType1.openAPINode() - // TODO: Write test + XCTAssertTrue(node.required) + XCTAssertEqual(node.jsonTypeFormat, .object(.generic)) - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - let string = String(data: try! encoder.encode(node), encoding: .utf8)! - print(string) + guard case let .object(contextA, objectContext1) = node else { + XCTFail("Expected Object node") + return + } + + XCTAssertEqual(contextA, .init(format: .generic, + required: true, + nullable: false, + allowedValues: nil)) + + XCTAssertEqual(objectContext1.minProperties, 2) + XCTAssertEqual(Set(objectContext1.requiredProperties), Set(["id", "type"])) + XCTAssertEqual(Set(objectContext1.properties.keys), Set(["id", "type"])) + XCTAssertEqual(objectContext1.properties["id"], .string(.init(format: .generic, + required: true), + .init())) + XCTAssertEqual(objectContext1.properties["type"], .string(.init(format: .generic, + required: true, + allowedValues: [.init(TestType1.jsonType)]), + .init())) } func test_AttributesEntity() { let node = try! TestType2.openAPINode() - // TODO: Write test + XCTAssertTrue(node.required) + XCTAssertEqual(node.jsonTypeFormat, .object(.generic)) + + guard case let .object(contextA, objectContext1) = node else { + XCTFail("Expected Object node") + return + } + + XCTAssertEqual(contextA, .init(format: .generic, + required: true, + nullable: false, + allowedValues: nil)) + + XCTAssertEqual(objectContext1.minProperties, 3) + XCTAssertEqual(Set(objectContext1.requiredProperties), Set(["id", "type", "attributes"])) + XCTAssertEqual(Set(objectContext1.properties.keys), Set(["id", "type", "attributes"])) + + XCTAssertEqual(objectContext1.properties["id"], .string(.init(format: .generic, + required: true), + .init())) + XCTAssertEqual(objectContext1.properties["type"], .string(.init(format: .generic, + required: true, + allowedValues: [.init(TestType2.jsonType)]), + .init())) + + let attributesNode = objectContext1.properties["attributes"] + + XCTAssertNotNil(attributesNode) + XCTAssertTrue(attributesNode?.required ?? false) + XCTAssertEqual(attributesNode?.jsonTypeFormat, .object(.generic)) + + guard case let .object(contextB, attributesContext)? = attributesNode else { + XCTFail("Expected Object node for attributes") + return + } + + XCTAssertEqual(contextB, .init(format: .generic, + required: true, + nullable: false, + allowedValues: nil)) + + XCTAssertEqual(attributesContext.minProperties, 3) + XCTAssertEqual(Set(attributesContext.requiredProperties), Set(["stringProperty", "enumProperty", "nullableProperty"])) + XCTAssertEqual(Set(attributesContext.properties.keys), Set(["stringProperty", "enumProperty", "optionalProperty", "nullableProperty", "nullableOptionalProperty"])) + + XCTAssertEqual(attributesContext.properties["stringProperty"], + .string(.init(format: .generic, + required: true), + .init())) + + XCTAssertEqual(attributesContext.properties["enumProperty"], + .string(.init(format: .generic, + required: true, + nullable: false, + allowedValues: ["one", "two"].map(AnyCodable.init)), + .init())) + + XCTAssertEqual(attributesContext.properties["optionalProperty"], + .string(.init(format: .generic, + required: false, + nullable: false, + allowedValues: nil), + .init())) + + XCTAssertEqual(attributesContext.properties["nullableProperty"], + .string(.init(format: .generic, + required: true, + nullable: true, + allowedValues: nil), + .init())) + + XCTAssertEqual(attributesContext.properties["nullableOptionalProperty"], + .string(.init(format: .generic, + required: false, + nullable: true, + allowedValues: nil), + .init())) + } + + func test_RelationshipsEntity() { + let node = try! TestType3.openAPINode() + + XCTAssertTrue(node.required) + XCTAssertEqual(node.jsonTypeFormat, .object(.generic)) + + guard case let .object(contextA, objectContext1) = node else { + XCTFail("Expected Object node") + return + } + + XCTAssertEqual(contextA, .init(format: .generic, + required: true, + nullable: false, + allowedValues: nil)) + + XCTAssertEqual(objectContext1.minProperties, 3) + XCTAssertEqual(Set(objectContext1.requiredProperties), Set(["id", "type", "relationships"])) + XCTAssertEqual(Set(objectContext1.properties.keys), Set(["id", "type", "relationships"])) + + XCTAssertEqual(objectContext1.properties["id"], .string(.init(format: .generic, + required: true), + .init())) + XCTAssertEqual(objectContext1.properties["type"], .string(.init(format: .generic, + required: true, + allowedValues: [.init(TestType3.jsonType)]), + .init())) + + let relationshipsNode = objectContext1.properties["relationships"] + + XCTAssertNotNil(relationshipsNode) + XCTAssertTrue(relationshipsNode?.required ?? false) + XCTAssertEqual(relationshipsNode?.jsonTypeFormat, .object(.generic)) + + guard case let .object(contextB, relationshipsContext)? = relationshipsNode else { + XCTFail("Expected Object node for relationships") + return + } + + XCTAssertEqual(contextB, .init(format: .generic, + required: true, + nullable: false, + allowedValues: nil)) + + XCTAssertEqual(relationshipsContext.minProperties, 3) + XCTAssertEqual(Set(relationshipsContext.requiredProperties), Set(["toOne", "nullableToOne", "toMany"])) + XCTAssertEqual(Set(relationshipsContext.properties.keys), Set(["toOne", "optionalTooOne", "nullableToOne", "nullableOptionalToOne", "toMany", "optionalToMany"])) + + let pointerDataContext = JSONNode.ObjectContext(properties: ["id": .string(.init(format: .generic, + required: true), + .init()), + "type": .string(.init(format: .generic, + required: true, + allowedValues: [.init(TestType1.jsonType)]), + .init())]) + + let pointerContext = JSONNode.ObjectContext(properties: ["data": .object(.init(format: .generic, + required: true), + pointerDataContext)]) + + let nullablePointerContext = JSONNode.ObjectContext(properties: ["data": .object(.init(format: .generic, + required: true, + nullable: true), + pointerDataContext)]) + + let manyPointerContext = JSONNode.ObjectContext(properties: ["data": .array(.init(format: .generic, + required: true), + .init(items: .object(.init(format: .generic, + required: true), + pointerDataContext)))]) + + XCTAssertEqual(relationshipsContext.properties["toOne"], + .object(.init(format: .generic, + required: true), + pointerContext)) + + XCTAssertEqual(relationshipsContext.properties["optionalTooOne"], + .object(.init(format: .generic, + required: false, + nullable: false, + allowedValues: nil), + pointerContext)) + + XCTAssertEqual(relationshipsContext.properties["nullableToOne"], + .object(.init(format: .generic, + required: true, + nullable: false, + allowedValues: nil), + nullablePointerContext)) + + XCTAssertEqual(relationshipsContext.properties["nullableOptionalToOne"], + .object(.init(format: .generic, + required: false, + nullable: false, + allowedValues: nil), + nullablePointerContext)) + + XCTAssertEqual(relationshipsContext.properties["toMany"], + .object(.init(format: .generic, + required: true), + manyPointerContext)) + + XCTAssertEqual(relationshipsContext.properties["optionalToMany"], + .object(.init(format: .generic, + required: false, + nullable: false, + allowedValues: nil), + manyPointerContext)) + } + + func test_AttributesAndRelationshipsEntity() { + // TODO: write test + + /* let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted let string = String(data: try! encoder.encode(node), encoding: .utf8)! print(string) + + */ } } @@ -46,7 +258,7 @@ extension JSONAPIEntityOpenAPITests { typealias TestType1 = BasicEntity enum TestType2Description: EntityDescription { - public static var jsonType: String { return "test1" } + public static var jsonType: String { return "test2" } public enum EnumType: String, CaseIterable, Codable, Equatable { case one @@ -56,13 +268,19 @@ extension JSONAPIEntityOpenAPITests { public struct Attributes: JSONAPI.Attributes, Sampleable { let stringProperty: Attribute let enumProperty: Attribute + let optionalProperty: Attribute? + let nullableProperty: Attribute + let nullableOptionalProperty: Attribute? var computedProperty: Attribute { return enumProperty } public static var sample: Attributes { return Attributes(stringProperty: .init(value: "hello"), - enumProperty: .init(value: .one)) + enumProperty: .init(value: .one), + optionalProperty: nil, + nullableProperty: .init(value: nil), + nullableOptionalProperty: nil) } } @@ -70,4 +288,33 @@ extension JSONAPIEntityOpenAPITests { } typealias TestType2 = BasicEntity + + enum TestType3Description: EntityDescription { + public static var jsonType: String { return "test3" } + + public typealias Attributes = NoAttributes + + public struct Relationships: JSONAPI.Relationships, Sampleable { + public let toOne: ToOneRelationship + public let optionalTooOne: ToOneRelationship? + public let nullableToOne: ToOneRelationship + public let nullableOptionalToOne: ToOneRelationship? + + public let toMany: ToManyRelationship + public let optionalToMany: ToManyRelationship? + // Note there is no such thing as nullable to-many relationships (Just use + // an empty array) + + public static var sample: Relationships { + return Relationships(toOne: .init(id: .init(rawValue: "1")), + optionalTooOne: nil, + nullableToOne: .init(id: nil), + nullableOptionalToOne: nil, + toMany: .init(ids: [.init(rawValue: "1")]), + optionalToMany: nil) + } + } + } + + typealias TestType3 = BasicEntity } diff --git a/Tests/JSONAPIOpenAPITests/XCTestManifests.swift b/Tests/JSONAPIOpenAPITests/XCTestManifests.swift index b05e1af..8f7c32b 100644 --- a/Tests/JSONAPIOpenAPITests/XCTestManifests.swift +++ b/Tests/JSONAPIOpenAPITests/XCTestManifests.swift @@ -30,10 +30,18 @@ extension JSONAPIAttributeOpenAPITests { ] } +extension JSONAPIDocumentOpenAPITests { + static let __allTests = [ + ("test_SingleResourceDocument", test_SingleResourceDocument), + ] +} + extension JSONAPIEntityOpenAPITests { static let __allTests = [ + ("test_AttributesAndRelationshipsEntity", test_AttributesAndRelationshipsEntity), ("test_AttributesEntity", test_AttributesEntity), ("test_EmptyEntity", test_EmptyEntity), + ("test_RelationshipsEntity", test_RelationshipsEntity), ] } @@ -52,6 +60,7 @@ extension JSONAPIRelationshipsOpenAPITests { public func __allTests() -> [XCTestCaseEntry] { return [ testCase(JSONAPIAttributeOpenAPITests.__allTests), + testCase(JSONAPIDocumentOpenAPITests.__allTests), testCase(JSONAPIEntityOpenAPITests.__allTests), testCase(JSONAPIRelationshipsOpenAPITests.__allTests), ]