A Swift package for encoding to- and decoding from JSON API compliant requests and responses.
See the JSON API Spec here: https://jsonapi.org/format/
ResourceObjects. Correct code will always compile, but tracking down the source of programmer errors can be an annoyance. This is mostly a concern when creating resource objects in-code (i.e. declaratively) like you might for unit testing. Writing a client that uses this framework to ingest and decode JSON API Compliant API responses is much less painful.
This library works well when used by both the server responsible for serialization and the client responsible for deserialization. Check out the example further down in this README.
The primary goals of this framework are:
- Allow creation of Swift types that are easy to use in code but also can be encoded to- or decoded from JSON API v1.0 Spec compliant payloads without lots of boilerplate code.
- Leverage
Codableto avoid additional outside dependencies and get operability with non-JSON encoders/decoders for free. - Do not sacrifice type safety.
- Be platform agnostic so that Swift code can be written once and used by both the client and the server.
- Provide human readable error output. The errors thrown when decoding an API response and the results of the
JSONAPITestingframework'scompare(to:)functions all have digestible human readable descriptions (just useString(describing:)).
The big caveat is that, although the aim is to support the JSON API spec, this framework ends up being naturally opinionated about certain things that the API Spec does not specify. These caveats are largely a side effect of attempting to write the library in a "Swifty" way.
If you find something wrong with this library and it isn't already mentioned under Project Status, let me know! I want to keep working towards a library implementation that is useful in any application.
- Swift 5.1+
- Swift Package Manager, Xcode 11+, or Cocoapods
Just include the following in your package's dependencies and add JSONAPI to the dependencies for any of your targets.
.package(url: "https://github.com/mattpolzin/JSONAPI.git", .upToNextMajor(from: "3.0.0"))
To create an Xcode project for JSONAPI, run
swift package generate-xcodeproj
With Xcode 11+ you can also just open the folder containing your clone of this repository and begin working.
To use this framework in your project via Cocoapods, add the following dependencies to your Podfile.
pod 'Poly', :git => 'https://github.com/mattpolzin/Poly.git'
pod 'MP-JSONAPI', :git => 'https://github.com/mattpolzin/JSONAPI.git'
To run the included Playground files, create an Xcode project using Swift Package Manager, then create an Xcode Workspace in the root of the repository and add both the generated Xcode project and the playground to the Workspace.
Note that Playground support for importing non-system Frameworks is still a bit touchy as of Swift 4.2. Sometimes building, cleaning and building, or commenting out and then uncommenting import statements (especially in the Entities.swift Playground Source file) can get things working for me when I am getting an error about JSONAPI not being found.
-
data -
included -
errors -
meta -
jsonapi(i.e. API Information) -
links
-
id -
type -
attributes -
relationships -
links -
meta
-
data -
links -
meta
-
href -
meta
- Support transforms on
Attributesvalues (e.g. to support different representations ofDate) - Support validation on
Attributes. - Support sparse fieldsets (encoding only). A client can likely just define a new model to represent a sparse population of another model in a very specific use case for decoding purposes. On the server side, sparse fieldsets of Resource Objects can be encoded without creating one model for every possible sparse fieldset.
- Disallow optional array in
Attribute(should be empty array, notnull). - Only allow
TransformedAttributeand its derivatives as stored properties withinAttributesstruct. Computed properties can still be any type because they do not get encoded or decoded. - Only allow
ToManyRelationshipandToOneRelationshipwithinRelationshipsstruct.
These ideas could be implemented in future versions.
- (Maybe) Use
KeyPathto specifyIncludesthus creating type safety around the relationship between a primary resource type and the types of included resources. - (Maybe) Replace
SingleResourceBodyandManyResourceBodywith support at theDocumentlevel to just interpretPrimaryResource,PrimaryResource?, or[PrimaryResource]as the same decoding/encoding strategies. - Support sideposting. JSONAPI spec might become opinionated in the future (json-api/json-api#1197, json-api/json-api#1215, json-api/json-api#1216) but there is also an existing implementation to consider (https://jsonapi-suite.github.io/jsonapi_suite/ruby/writes/nested-writes). At this time, any sidepost implementation would be an awesome tertiary library to be used alongside the primary JSONAPI library. Maybe
JSONAPISideloading. - Error or warning if an included resource object is not related to a primary resource object or another included resource object (Turned off or at least not throwing by default).
The following serves as a sort of pseudo-example. It skips server/client implementation details not related to JSON:API but still gives a more complete picture of what an implementation using this framework might look like. You can play with this example code in the Playground provided with this repo.
// Make String a CreatableRawIdType.
var globalStringId: Int = 0
extension String: CreatableRawIdType {
public static func unique() -> String {
globalStringId += 1
return String(globalStringId)
}
}
// Create a typealias because we do not expect JSON:API Resource
// Objects for this particular API to have Metadata or Links associated
// with them. We also expect them to have String Identifiers.
typealias JSONEntity<Description: ResourceObjectDescription> = JSONAPI.ResourceObject<Description, NoMetadata, NoLinks, String>
// Similarly, create a typealias for unidentified entities. JSON:API
// only allows unidentified entities (i.e. no "id" field) for client
// requests that create new entities. In these situations, the server
// is expected to assign the new entity a unique ID.
typealias UnidentifiedJSONEntity<Description: ResourceObjectDescription> = JSONAPI.ResourceObject<Description, NoMetadata, NoLinks, Unidentified>
// Create relationship typealiases because we do not expect
// JSON:API Relationships for this particular API to have
// Metadata or Links associated with them.
typealias ToOneRelationship<Entity: Identifiable> = JSONAPI.ToOneRelationship<Entity, NoMetadata, NoLinks>
typealias ToManyRelationship<Entity: Relatable> = JSONAPI.ToManyRelationship<Entity, NoMetadata, NoLinks>
// Create a typealias for a Document because we do not expect
// JSON:API Documents for this particular API to have Metadata, Links,
// useful Errors, or an APIDescription (The *SPEC* calls this
// "API Description" the "JSON:API Object").
typealias Document<PrimaryResourceBody: JSONAPI.CodableResourceBody, IncludeType: JSONAPI.Include> = JSONAPI.Document<PrimaryResourceBody, NoMetadata, NoLinks, IncludeType, NoAPIDescription, BasicJSONAPIError<String>>
// MARK: Entity Definitions
enum AuthorDescription: ResourceObjectDescription {
public static var jsonType: String { return "authors" }
public struct Attributes: JSONAPI.Attributes {
public let name: Attribute<String>
}
public typealias Relationships = NoRelationships
}
typealias Author = JSONEntity<AuthorDescription>
enum ArticleDescription: ResourceObjectDescription {
public static var jsonType: String { return "articles" }
public struct Attributes: JSONAPI.Attributes {
public let title: Attribute<String>
public let abstract: Attribute<String>
}
public struct Relationships: JSONAPI.Relationships {
public let author: ToOneRelationship<Author>
}
}
typealias Article = JSONEntity<ArticleDescription>
// MARK: Document Definitions
// We create a typealias to represent a document containing one Article
// and including its Author
typealias SingleArticleDocumentWithIncludes = Document<SingleResourceBody<Article>, Include1<Author>>
// ... and a typealias to represent a document containing one Article and
// not including any related entities.
typealias SingleArticleDocument = Document<SingleResourceBody<Article>, NoIncludes>// Skipping over all the API and database stuff, here's a chunk of code
// that creates a document. Note that this document is the entirety
// of a JSON:API response body.
func articleDocument(includeAuthor: Bool) -> Either<SingleArticleDocument, SingleArticleDocumentWithIncludes> {
// Let's pretend all of this is coming from a database:
let authorId = Author.Identifier(rawValue: "1234")
let article = Article(id: .init(rawValue: "5678"),
attributes: .init(title: .init(value: "JSON:API in Swift"),
abstract: .init(value: "Not yet written")),
relationships: .init(author: .init(id: authorId)),
meta: .none,
links: .none)
let document = SingleArticleDocument(apiDescription: .none,
body: .init(resourceObject: article),
includes: .none,
meta: .none,
links: .none)
switch includeAuthor {
case false:
return .init(document)
case true:
let author = Author(id: authorId,
attributes: .init(name: .init(value: "Janice Bluff")),
relationships: .none,
meta: .none,
links: .none)
let includes: Includes<SingleArticleDocumentWithIncludes.Include> = .init(values: [.init(author)])
return .init(document.including(includes))
}
}
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.outputFormatting = .prettyPrinted
let responseBody = articleDocument(includeAuthor: true)
let responseData = try! encoder.encode(responseBody)
// Next step would be setting the HTTP body of a response.
// We will just print it out instead:
print("-----")
print(String(data: responseData, encoding: .utf8)!)
// ... and if we had received a request for an article without
// including the author:
let otherResponseBody = articleDocument(includeAuthor: false)
let otherResponseData = try! encoder.encode(otherResponseBody)
print("-----")
print(String(data: otherResponseData, encoding: .utf8)!)enum NetworkError: Swift.Error {
case serverError
case quantityMismatch
}
// Skipping over all the API stuff, here's a chunk of code that will
// decode a document. We will assume we have made a request for a
// single article including the author.
func docode(articleResponseData: Data) throws -> (article: Article, author: Author) {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let articleDocument = try decoder.decode(SingleArticleDocumentWithIncludes.self, from: articleResponseData)
switch articleDocument.body {
case .data(let data):
let authors = data.includes[Author.self]
guard authors.count == 1 else {
throw NetworkError.quantityMismatch
}
return (article: data.primary.value, author: authors[0])
case .errors(let errors, meta: _, links: _):
throw NetworkError.serverError
}
}
let response = try! docode(articleResponseData: responseData)
// Next step would be to do something useful with the article and author but we will print them instead.
print("-----")
print(response.article)
print(response.author)See the usage documentation.
The JSONAPI framework is packaged with a test library to help you test your JSONAPI integration. The test library is called JSONAPITesting. You can see JSONAPITesting in action in the Playground included with the JSONAPI repository.
Literal expressibility for Attribute, ToOneRelationship, and Id are provided so that you can easily write test ResourceObject values into your unit tests.
For example, you could create a mock Author (from the above example) as follows
let author = Author(
id: "1234", // You can just use a String directly as an Id
attributes: .init(name: "Janice Bluff"), // The name Attribute does not need to be initialized, you just use a String directly.
relationships: .none,
meta: .none,
links: .none
)The ResourceObject gets a check() function that can be used to catch problems with your JSONAPI structures that are not caught by Swift's type system.
To catch malformed JSONAPI.Attributes and JSONAPI.Relationships, just call check() in your unit test functions:
func test_initAuthor() {
let author = Author(...)
Author.check(author)
}You can compare Documents, ResourceObjects, Attributes, etc. and get human-readable output using the compare(to:) methods included with JSONAPITesting.
func test_articleResponse() {
let endToEndAPITestResponse: SingleArticleDocumentWithIncludes = ...
let expectedResponse: SingleArticleDocumentWithIncludes = ...
let comparison = endToEndAPITestResponse.compare(to: expectedResponse)
XCTAssert(comparison.isSame, String(describing: comparison))
}The JSONAPI+Arbitrary library provides SwiftCheck Arbitrary conformance for many of the JSONAPI types.
See https://github.com/mattpolzin/JSONAPI-Arbitrary for more information.
The JSONAPI+OpenAPI library generates OpenAPI compliant JSON Schema for models built with the JSONAPI library. If your Swift code is your preferred source of truth for API information, this is an easy way to document the response schemas of your API.
JSONAPI+OpenAPI also has experimental support for generating JSONAPI Swift code from Open API documentation (this currently lives on the feature/gen-swift branch).
See https://github.com/mattpolzin/JSONAPI-OpenAPI for more information.