diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4d446af..57ce713 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,4 @@ -https://github.com/nimblehq/git-template/issues/?? +https://github.com/nimblehq/JSONMapper/issues/?? ## What happened 👀 diff --git a/.gitignore b/.gitignore index cc9c8bd..f623494 100644 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,47 @@ -*.gem -*.rbc -/.config -/.idea -/coverage/ -/InstalledFiles -/node-modules -/pkg/ -/spec/reports/ -/spec/examples.txt -/test/tmp/ -/test/version_tmp/ -/tmp/ - -# Used by dotenv library to load environment variables. -# .env - -## Documentation cache and generated files: -/.yardoc/ -/_yardoc/ -/doc/ -/rdoc/ - -## Environment normalization: -/.bundle/ -/vendor/bundle -/lib/bundler/man/ - -# for a library or gem, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# Gemfile.lock -# .ruby-version -# .ruby-gemset - -# unless supporting rvm < 1.11.0 or doing something fancy, ignore this: -.rvmrc +# Created by https://www.gitignore.io/api/swift +# Edit at https://www.gitignore.io/?templates=swift + +### Swift ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +build/ +DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +## Other +*.moved-aside +*.xccheckout +*.xcscmblueprint + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +.build/ + +# End of https://www.gitignore.io/api/swift +.DS_Store diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..4c8877d --- /dev/null +++ b/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version:5.3 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "JSONMapper", + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "JSONMapper", + targets: ["JSONMapper"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "JSONMapper", + dependencies: []), + .testTarget( + name: "JSONMapperTests", + dependencies: ["JSONMapper"]), + ] +) diff --git a/README.md b/README.md index ab4d4cb..8b16730 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,19 @@ -# Git Repository Template +# JSONMapper -Project repository template to set up all public projects at [Nimble](https://nimblehq.co/) +An iOS library supports mapping json data -## Usage +## Installation -Clone the repository +### Swift Package Manager + +The Swift Package Manager is a tool for automating the distribution of Swift code and is integrated into the swift compiler. It is in early development, but Alamofire does support its use on supported platforms. + +Once you have your Swift package set up, adding Alamofire as a dependency is as easy as adding it to the dependencies value of your Package.swift. + +dependencies: [ + .package(url: "https://github.com/nimblehq/JSONMapper.git", .upToNextMajor(from: "1.0.0")) +] -`git clone git@github.com:nimblehq/git-template.git` ## License diff --git a/Sources/JSONMapper/Extensions/Encodable+Dictionary.swift b/Sources/JSONMapper/Extensions/Encodable+Dictionary.swift new file mode 100644 index 0000000..47833cb --- /dev/null +++ b/Sources/JSONMapper/Extensions/Encodable+Dictionary.swift @@ -0,0 +1,21 @@ +// +// Encodable+Dictionary.swift +// NimbleExtension +// +// Created by Tam Nguyen on 11/17/20. +// Copyright © 2020 Nimble. All rights reserved. +// + +import Foundation + +public extension Encodable { + + func toDictionary(_ encoder: JSONEncoder = JSONEncoder()) -> [String: Any] { + guard let dictionary = try? JSONSerialization.jsonObject( + with: encoder.encode(self), options: .allowFragments + ) as? [String: Any] else { + return [:] + } + return dictionary + } +} diff --git a/Sources/JSONMapper/Extensions/Optional+Extras.swift b/Sources/JSONMapper/Extensions/Optional+Extras.swift new file mode 100644 index 0000000..41fbb2e --- /dev/null +++ b/Sources/JSONMapper/Extensions/Optional+Extras.swift @@ -0,0 +1,35 @@ +// Optional+Or.swift +// +// Created by Edgars Simanovskis on 27/08/2019. +// Copyright © 2018 Nimble. All rights reserved. +// + +public extension Optional { + + var isNil: Bool { + return self == nil + } + + var hasValue: Bool { !isNil } + + func or(_ otherOptional: @autoclosure () throws -> Wrapped?) rethrows -> Wrapped? { + switch self { + case .some(let value): return value + case .none: return try otherOptional() + } + } + + func or(_ otherWrapped: @autoclosure () throws -> Wrapped) rethrows -> Wrapped { + switch self { + case .some(let value): return value + case .none: return try otherWrapped() + } + } + + func resolve(with error: @autoclosure () -> Error) throws -> Wrapped { + switch self { + case .none: throw error() + case .some(let wrapped): return wrapped + } + } +} diff --git a/Sources/JSONMapper/JSONAPIDecoder.swift b/Sources/JSONMapper/JSONAPIDecoder.swift new file mode 100644 index 0000000..844f688 --- /dev/null +++ b/Sources/JSONMapper/JSONAPIDecoder.swift @@ -0,0 +1,135 @@ +// JSONAPIDecoder.swift +// +// Created by Pirush Prechathavanich on 4/4/18. +// Copyright © 2018 Nimble. All rights reserved. +// + +import Foundation + +public class JSONAPIDecoder: JSONDecoder { + + private typealias ResourceDictionary = [ResourceIdentifier: Resource] + + private let decoder: JSONEncoder + + init(decoder: JSONEncoder = JSONEncoder()) { + self.decoder = decoder + } + + public override func decode(_ type: T.Type, from data: Data) throws -> T where T: Decodable { + let jsonAPIObject = try super.decode(JSONAPIObject.self, from: data) + + let includedData = jsonAPIObject.included ?? [] + let dictionary = includedDictionary(from: includedData) + + switch jsonAPIObject.type { + case .data(let data): + return try decode(data, including: dictionary, into: type) + case .meta(let meta): + return try decode(meta, into: type) + case .errors(let errors): + throw errors + } + } + + public func decodeWithMeta( + value valueType: Value.Type, + meta metaType: Meta.Type, + from data: Data + ) throws -> (value: Value, meta: Meta) { + let jsonAPIObject = try super.decode(JSONAPIObject.self, from: data) + + let includedData = jsonAPIObject.included ?? [] + let dictionary = includedDictionary(from: includedData) + + switch jsonAPIObject.type { + case .data(let data): + return ( + value: try decode(data, including: dictionary, into: valueType), + meta: try decode(jsonAPIObject.meta ?? .nil, into: metaType) + ) + case .meta(let meta): + throw Errors.JSONAPIDecodingError.unableToDecode( + reason: "No data field. Only contains meta: \(meta)" + ) + case .errors(let errors): + throw errors + } + } + +} + +// MARK: - Private + +extension JSONAPIDecoder { + + private func decode(_ meta: JSON, into type: T.Type) throws -> T { + let data = try decoder.encode(meta) + return try super.decode(type, from: data) + } + + private func decode(_ dataType: DataType, + including includedDictionary: ResourceDictionary, + into type: T.Type) throws -> T { + switch dataType { + case .single(let resource): + return try decode(resource, including: includedDictionary, into: type) + case .collection(let resources): + return try decodeCollection(of: resources, including: includedDictionary, into: type) + } + } + + private func decode(_ resource: Resource, + including includedDictionary: ResourceDictionary, + into type: T.Type) throws -> T { + let dictionary = try resolvedAttributes(of: resource, including: includedDictionary) + let data = try decoder.encode(dictionary) + return try super.decode(type, from: data) + } + + private func decodeCollection(of resources: [Resource], + including includedDictionary: ResourceDictionary, + into type: T.Type) throws -> T { + let collection = try resources.compactMap { try resolvedAttributes(of: $0, including: includedDictionary) } + let data = try decoder.encode(collection) + return try super.decode(type, from: data) + } + + private func includedDictionary(from includedData: [Resource]) -> ResourceDictionary { + return includedData.reduce(into: [:]) { dictionary, resource in + let identifier = ResourceIdentifier(id: resource.id, type: resource.type) + dictionary[identifier] = resource + } + } + + private func resolvedAttributes(of resource: Resource, + including includedDictionary: ResourceDictionary) throws -> JSON? { + var attributes = resource.attributes?.nested ?? [:] + attributes[Resource.CodingKeys.id.rawValue] = .string(resource.id) + attributes[Resource.CodingKeys.type.rawValue] = .string(resource.type) + + try resource.relationships?.forEach { key, relationship in + guard let type = relationship.data else { return } + switch type { + case .single(let identifier): + let includedResource = try getResource(from: includedDictionary, for: identifier) + attributes[key] = try resolvedAttributes(of: includedResource, including: includedDictionary) + + case .collection(let identifiers): + let includedAttributes = try identifiers + .map { try getResource(from: includedDictionary, for: $0) } + .compactMap { try resolvedAttributes(of: $0, including: includedDictionary) } + attributes[key] = .array(includedAttributes) + } + } + return .nested(attributes) + } + + private func getResource(from includedDictionary: ResourceDictionary, + for identifier: ResourceIdentifier) throws -> Resource { + guard let resource = includedDictionary[identifier] else { + throw Errors.JSONAPIDecodingError.resourceNotFound(identifier: identifier) + } + return resource + } +} diff --git a/Sources/JSONMapper/Models/DataType.swift b/Sources/JSONMapper/Models/DataType.swift new file mode 100644 index 0000000..97be282 --- /dev/null +++ b/Sources/JSONMapper/Models/DataType.swift @@ -0,0 +1,30 @@ +// DataType.swift +// +// Created by Edgars Simanovskis on 27/08/2019. +// Copyright © 2018 Nimble. All rights reserved. +// + +import Foundation + +public enum DataType: Codable { + + case single(T) + case collection([T]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + do { + self = try .single(container.decode(T.self)) + } catch DecodingError.typeMismatch { + self = try .collection(container.decode([T].self)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .single(let object): try container.encode(object) + case .collection(let array): try container.encode(array) + } + } +} diff --git a/Sources/JSONMapper/Models/JSON.swift b/Sources/JSONMapper/Models/JSON.swift new file mode 100644 index 0000000..57c9034 --- /dev/null +++ b/Sources/JSONMapper/Models/JSON.swift @@ -0,0 +1,95 @@ +// JSON.swift +// +// Created by Pirush Prechathavanich on 4/4/18. +// Copyright © 2018 Nimble. All rights reserved. +// +// swiftlint:disable multiline_function_chains + +public enum JSON: Codable { + + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case nested([String: JSON]) + case array([JSON]) + case `nil` + + public var string: String? { + guard case .string(let value) = self else { return nil } + return value + } + + public var int: Int? { + guard case .int(let value) = self else { return nil } + return value + } + + // swiftlint:disable:next discouraged_optional_boolean + public var bool: Bool? { + guard case .bool(let value) = self else { return nil } + return value + } + + public var double: Double? { + guard case .double(let value) = self else { return nil } + return value + } + + public var nested: [String: JSON]? { + guard case .nested(let value) = self else { return nil } + return value + } + + public var array: [JSON]? { + guard case .array(let value) = self else { return nil } + return value + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self = try container.decodeIfPresent(String.self).map(JSON.string) + .or(container.decodeIfPresent(Int.self).map(JSON.int)) + .or(container.decodeIfPresent(Double.self).map(JSON.double)) + .or(container.decodeIfPresent(Bool.self).map(JSON.bool)) + .or(container.decodeIfPresent([String: JSON].self).map(JSON.nested)) + .or(container.decodeIfPresent([JSON].self).map(JSON.array)) + .or(container.decodeNil() ? .nil : nil) + .resolve(with: JSON.typeMismatchError(for: container.codingPath)) + } + + // MARK: - private helpers + private static func typeMismatchError(for codingPath: [CodingKey]) -> DecodingError { + let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Unsupported JSON value") + return DecodingError.typeMismatch(JSON.self, context) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let string): try container.encode(string) + case .int(let int): try container.encode(int) + case .double(let double): try container.encode(double) + case .bool(let bool): try container.encode(bool) + case .nested(let nested): try container.encode(nested) + case .array(let array): try container.encode(array) + case .nil: try container.encodeNil() + } + } + + public subscript(key: String) -> JSON? { + get { return nested?[key] } + set { + if case .nested(var dictionary) = self { + dictionary[key] = newValue + self = .nested(dictionary) + } + } + } +} + +private extension SingleValueDecodingContainer { + func decodeIfPresent(_ type: T.Type) -> T? { + return try? decode(type) + } +} diff --git a/Sources/JSONMapper/Models/JSONAPIDecodingError.swift b/Sources/JSONMapper/Models/JSONAPIDecodingError.swift new file mode 100644 index 0000000..eac645e --- /dev/null +++ b/Sources/JSONMapper/Models/JSONAPIDecodingError.swift @@ -0,0 +1,15 @@ +// JSONAPIErrorSource.swift +// +// Created by Issarapong Poesua on 18/9/18. +// Copyright © 2018 Nimble. All rights reserved. +// + +import Foundation + +public enum Errors: Error { + public enum JSONAPIDecodingError: LocalizedError { + case resourceNotFound(identifier: ResourceIdentifier) + case invalidFormat(reason: String) + case unableToDecode(reason: String) + } +} diff --git a/Sources/JSONMapper/Models/JSONAPIError.swift b/Sources/JSONMapper/Models/JSONAPIError.swift new file mode 100644 index 0000000..666e9e1 --- /dev/null +++ b/Sources/JSONMapper/Models/JSONAPIError.swift @@ -0,0 +1,26 @@ +// JSONAPIError.swift +// +// Created by Pirush Prechathavanich on 4/25/18. +// Copyright © 2018 Nimbl3. All rights reserved. +// + +import Foundation + +public struct JSONAPIError: Error, Decodable, Equatable { + + public struct Source: Decodable, Equatable { + let parameter: String? + } + + public let id: String? + public let title: String? + public let detail: String? + public let source: Source? + /// http status code of the error + public let status: String? + /// application-specific error code + public let code: String? +} + +/// JSON:API error object is sent as an array of errors. +extension Array: Error where Element == JSONAPIError {} diff --git a/Sources/JSONMapper/Models/JSONAPIObject.swift b/Sources/JSONMapper/Models/JSONAPIObject.swift new file mode 100644 index 0000000..5c5fd3c --- /dev/null +++ b/Sources/JSONMapper/Models/JSONAPIObject.swift @@ -0,0 +1,66 @@ +// JSONAPIObject.swift +// +// Created by Pirush Prechathavanich on 4/4/18. +// Copyright © 2018 Nimble. All rights reserved. +// + +import Foundation + +public enum JSONAPIResponseType { + + case data(DataType) + case errors([JSONAPIError]) + case meta(JSON) +} + +public struct JSONAPIObject: Decodable { + + enum CodingKeys: String, CodingKey { + case data, errors, meta, links, included + } + + public let type: JSONAPIResponseType + + public let links: Links? + public let included: [Resource]? + public let meta: JSON? + + public var data: DataType? { + if case .data(let data) = type { return data } + return nil + } + + public var errors: [JSONAPIError]? { + if case .errors(let errors) = type { return errors } + return nil + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let data = try container.decodeIfPresent(DataType.self, forKey: .data) + let errors = try container.decodeIfPresent([JSONAPIError].self, forKey: .errors) + + if data.hasValue && errors.hasValue { + throw Errors.JSONAPIDecodingError.invalidFormat( + reason: "Data and errors shouldn't co-exist in the same JSON:API object" + ) + } + + meta = try container.decodeIfPresent(JSON.self, forKey: .meta) + + if let dataObject = data { + type = .data(dataObject) + } else if let errorObjects = errors { + type = .errors(errorObjects) + } else if let meta = meta { + type = .meta(meta) + } else { + throw Errors.JSONAPIDecodingError.invalidFormat( + reason: "Either one of data, errors, or meta should have value") + } + + links = try container.decodeIfPresent(Links.self, forKey: .links) + included = try container.decodeIfPresent([Resource].self, forKey: .included) + } +} diff --git a/Sources/JSONMapper/Models/JSONAPIType.swift b/Sources/JSONMapper/Models/JSONAPIType.swift new file mode 100644 index 0000000..5cf3189 --- /dev/null +++ b/Sources/JSONMapper/Models/JSONAPIType.swift @@ -0,0 +1,13 @@ +// JSONAPIType.swift +// +// Created by Pirush Prechathavanich on 4/5/18. +// Copyright © 2018 Nimbl3. All rights reserved. +// + +typealias JSONAPICodable = JSONAPIType & Codable + +public protocol JSONAPIType { + + var id: String { get } + var type: String { get } +} diff --git a/Sources/JSONMapper/Models/Link.swift b/Sources/JSONMapper/Models/Link.swift new file mode 100644 index 0000000..1e038be --- /dev/null +++ b/Sources/JSONMapper/Models/Link.swift @@ -0,0 +1,30 @@ +// Link.swift +// +// Created by Pirush Prechathavanich on 4/4/18. +// Copyright © 2018 Nimbl3. All rights reserved. +// + +import Foundation + +public struct Link: Codable { + + enum CodingKeys: String, CodingKey { + + case url = "href" + case meta + } + + let url: URL + let meta: JSON? + + public init(from decoder: Decoder) throws { + if let container = try? decoder.singleValueContainer() { + url = try container.decode(URL.self) + meta = nil + } else { + let container = try decoder.container(keyedBy: CodingKeys.self) + url = try container.decode(URL.self, forKey: .url) + meta = try container.decodeIfPresent(JSON.self, forKey: .meta) + } + } +} diff --git a/Sources/JSONMapper/Models/Links.swift b/Sources/JSONMapper/Models/Links.swift new file mode 100644 index 0000000..74b1aec --- /dev/null +++ b/Sources/JSONMapper/Models/Links.swift @@ -0,0 +1,20 @@ +// Links.swift +// +// Created by Pirush Prechathavanich on 4/4/18. +// Copyright © 2018 Nimbl3. All rights reserved. +// + +import Foundation + +public struct Links: Codable { + + enum CodingKeys: String, CodingKey { + case selfURL = "self" + case relatedURL = "related" + case articleURL = "article" + } + + public let selfURL: Link? + public let relatedURL: Link? + public let articleURL: Link? +} diff --git a/Sources/JSONMapper/Models/Relationship.swift b/Sources/JSONMapper/Models/Relationship.swift new file mode 100644 index 0000000..8b50811 --- /dev/null +++ b/Sources/JSONMapper/Models/Relationship.swift @@ -0,0 +1,33 @@ +// +// Relationship.swift +// +// Created by Pirush Prechathavanich on 4/4/18. +// Copyright © 2018 Nimbl3. All rights reserved. +// + +import Foundation + +public struct Relationship: Codable { + + enum CodingKeys: String, CodingKey { + case links, data, meta + } + + public let links: Links? + public let data: DataType? + public let meta: JSON? + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + links = try container.decodeIfPresent(Links.self, forKey: .links) + data = try container.decodeIfPresent(DataType.self, forKey: .data) + meta = try container.decodeIfPresent(JSON.self, forKey: .meta) + + if links.isNil && data.isNil && meta.isNil { + guard try !container.decodeNil(forKey: .data) else { return } + let description = "Relationship object must contain at least one of links, data, or meta" + let context = DecodingError.Context(codingPath: container.codingPath, debugDescription: description) + throw DecodingError.typeMismatch(Relationship.self, context) + } + } +} diff --git a/Sources/JSONMapper/Models/Resource.swift b/Sources/JSONMapper/Models/Resource.swift new file mode 100644 index 0000000..80d20c0 --- /dev/null +++ b/Sources/JSONMapper/Models/Resource.swift @@ -0,0 +1,32 @@ +// Resource.swift +// +// Created by Pirush Prechathavanich on 4/4/18. +// Copyright © 2018 Nimbl3. All rights reserved. +// + +import Foundation + +public struct Resource: JSONAPICodable { + + enum CodingKeys: String, CodingKey { + case id, type, attributes, relationships, links, meta + } + + public let id: String + public let type: String + + public let attributes: JSON? + public let relationships: [String: Relationship]? + public let links: Links? + public let meta: JSON? + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + type = try container.decode(String.self, forKey: .type) + attributes = try container.decodeIfPresent(JSON.self, forKey: .attributes) + relationships = try container.decodeIfPresent([String: Relationship].self, forKey: .relationships) + links = try container.decodeIfPresent(Links.self, forKey: .links) + meta = try container.decodeIfPresent(JSON.self, forKey: .meta) + } +} diff --git a/Sources/JSONMapper/Models/ResourceIdentifier.swift b/Sources/JSONMapper/Models/ResourceIdentifier.swift new file mode 100644 index 0000000..e2521bc --- /dev/null +++ b/Sources/JSONMapper/Models/ResourceIdentifier.swift @@ -0,0 +1,26 @@ +// ResourceIdentifier.swift +// +// Created by Pirush Prechathavanich on 4/4/18. +// Copyright © 2018 Nimbl3. All rights reserved. +// + +import Foundation + +public protocol ResourceIdentifiable { + var id: String { get } + var type: String { get } +} + +public struct ResourceIdentifier: JSONAPICodable, Hashable { + public let id: String + public let type: String +} + +//todo:- continue working on this one after relationships field is required +// to be as a parameter of JSON:API request. + +public extension ResourceIdentifiable { + var resourceIdentifier: ResourceIdentifier { + return ResourceIdentifier(id: id, type: type) + } +} diff --git a/Tests/JSONMapperTests/JSONMapperTests.swift b/Tests/JSONMapperTests/JSONMapperTests.swift new file mode 100644 index 0000000..2f8e83f --- /dev/null +++ b/Tests/JSONMapperTests/JSONMapperTests.swift @@ -0,0 +1,4 @@ +import XCTest +@testable import JSONMapper + +final class JSONMapperTests: XCTestCase {}