JSONAPI
A Swift package for encoding and decoding to JSON API compliant requests and responses.
See the JSON API Spec here: https://jsonapi.org/format/
Primary Goals
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 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.
Project Status
Decoding
Document
dataincludederrors(untested)metajsonapilinks
Resource Object
idtypeattributesrelationshipslinksmeta
Relationship Object
datalinksmeta
Encoding
Document
dataincludederrors(untested)metajsonapilinks
Resource Object
idtypeattributesrelationshipslinksmeta
Relationship Object
datalinksmeta
EntityDescription Validator (using reflection)
- Disallow optional array in
AttributeandRelationship(should be empty array, notnull). - Only allow
AttributeandTransformAttributewithinAttributesstruct. - Only allow
ToManyRelationshipandToOneRelationshipwithinRelationshipsstruct.
Strict Decoding/Encoding Settings
- Error (potentially while still encoding/decoding successfully) if an included entity is not related to a primary entity (Turned off by default).
Misc
- Support transforms on
Attributesvalues (e.g. to support different representations ofDate) - Support ability to distinguish between
Attributesfields that are optional (i.e. the key might not be there) andAttributesvalues that are optional (i.e. the key is guaranteed to be there but it might benull). - Fix
ToOneRelationshipso that it is possible to specify an optional relationship where the value isnullrather than the key being omitted. - Conform to
CustomStringConvertible - More tests around failing to decode improperly structured JSON (not bad JSON, but JSON that is not to spec)
- Use
KeyPathto specifyIncludesthus creating type safety around the relationship between a primary resource type and the types of included resources???? - For
NoIncludes, do not even loop over the "included" JSON API section if it exists. - Property-based testing (using
SwiftCheck) - Roll my own
Resultor find an alternative that doesn't useFoundation. - Create more descriptive errors that are easier to use for troubleshooting.
Usage
Prerequisites
- Swift 4.2+ and Swift Package Manager
EntityDescription
An EntityDescription is the JSONAPI framework's specification for what the JSON API spec calls a Resource Object. You might create the following EntityDescription to represent a person in a network of friends:
enum PersonDescription: IdentifiedEntityDescription {
static var type: String { return "people" }
struct Attributes: JSONAPI.Attributes {
let name: Attribute<[String]>
let favoriteColor: Attribute<String>
}
struct Relationships: JSONAPI.Relationships {
let friends: ToManyRelationship<Person>
}
}
To enumerate them, the requirements of an EntityDescription are
- A static
var"type" that matches the JSON type; The JSON spec requires every Resource Object to have a "type". - A
structofAttributes- OR -typealias Attributes = NoAttributes - A
structofRelationships- OR -typealias Relationships = NoRelatives
Note that an enum type is used here for the EntityDescription; it could have been a struct, but EntityDescriptions do not ever need to be created so an enum with no cases is a nice fit for the job.
This readme doesn't go into detail on the JSON API Spec, but the following JSON API Resource Object would be described by the above PersonDescription:
{
"type": "people",
"id": "9",
"attributes": {
"name": [
"Jane",
"Doe"
],
"favoriteColor": "Green"
},
"relationships": {
"friends": {
"data": [
{
"id": "7",
"type": "people"
},
{
"id": "8",
"type": "people"
}
]
}
}
}
Entity
Once you have an EntityDescription, you create, encode, and decode Entitys that "fit the description". If you have a CreatableRawIdType (see the section on RawIdTypes below) then you can create new Entitys that will automatically be given unique Ids, but even without a CreatableRawIdType you can encode, decode and work with entities.
The Entity and EntityDescription together embody the rules and properties of a JSON API Resource Object.
An Entity needs to be specialized on two generic types. The first is the EntityDescription described above. The second is the type of Id to use for the Entity.
IdType
An IdType packages up two pieces of information: A unique identifier of a given RawIdType and the EntityDescription of the type of entity the Id identifies. Having the EntityDescription type associated with the Id makes it easy to store all of your entities in a local hash broken out by EntityDescription; You can pass Ids around and always know where to look for the Entity to which the Id refers. RawIdTypes are documented below.
Convenient typealiases
Often you can use one RawIdType for many if not all of your Entities. That means you can save yourself some boilerplate by using typealiases like the following:
public typealias Entity<Description: JSONAPI.EntityDescription> = JSONAPI.Entity<Description, Id<String, Description>>
public typealias NewEntity<Description: JSONAPI.EntityDescription> = JSONAPI.Entity<Description, Unidentified>
It can also be nice to create a typealias for each type of entity you want to work with:
typealias Person = Entity<PersonDescription>
typealias NewPerson = NewEntity<PersonDescription>
Note that I am assuming an unidentified person is a "new" person. I suspect that is generally an acceptable conflation because the only time JSON API spec allows a Resource Object to be encoded without an Id is when a client is requesting the given Resource Object be created by the server and the client wants the server to create the Id for that object.
Relationships
There are two types of Relationships: ToOneRelationship and ToManyRelationship. An EntityDescription's Relationships type can contain any number of Relationships of either of these types. Do not store anything other than Relationships in the Relationships struct of an EntityDescription.
To describe a relationship that may be omitted (i.e. the key is not even present in the JSON object), you make the entire ToOneRelationship or ToManyRelationship optional. However, this is not recommended because you can also represent optional relationships as nullable which means the key is always present. A ToManyRelationship can naturally represent no related objects exist with an empty array, so ToManyRelationship does not support nullability at all. A ToOneRelationship can be marked as nullable (i.e. the value might be null or it might be a resource identifier) like this:
let nullableRelative: ToOneRelationship<Person?>
An entity that does not have relationships can be described by adding the following to an EntityDescription:
typealias Relationships = NoRelatives
Relationships boil down to Ids of other entities. To access the Id of a related entity, you can use the shorthand ~> operator with the KeyPath of the Relationship from which you want the Id. The friends of the above Person entity could be accessed as follows (type annotations for clarity):
let friendIds: [Person.Identifier] = person ~> \.friends
Attributes
The Attributes of an EntityDescription can contain any JSON encodable/decodable types as long as they are wrapped in an Attribute or TransformAttribute struct. This is the place to store all attributes of an entity.
To describe an attribute that may be omitted (i.e. the key might not even be in the JSON object), you make the entire Attribute optional:
let optionalAttribute: Attribute<String>?
To describe an attribute that is expected to exist but might have a null value, you make the value within the Attribute optional:
let nullableAttribute: Attribute<String?>
An entity that does not have attributes can be described by adding the following to an EntityDescription:
typealias Attributes = NoAttributes
Attributes can be accessed via the subscript operator of the Entity type as follows:
let favoriteColor: String = person[\.favoriteColor]
Transformer
Sometimes you need to use a type that does not encode or decode itself in the way you need to represent it as a serialized JSON object. For example, the Swift Foundation type Date can encode/decode itself to Double out of the box, but you might want to represent dates as ISO 8601 compliant Strings instead. To do this, you create a Transformer.
A Transformer just provides one static function that transforms one type to another. You might define one for an ISO 8601 compliant Date like this:
enum ISODateTransformer: Transformer {
public static func transform(_ from: String) throws -> Date {
// parse Date out of input and return
}
}
Then you define the attribute as a TransformAttribute instead of an Attribute:
let date: TransformAttribute<String, ISODateTransformer>
Note that the first generic parameter of TransformAttribute is the type you expect to decode from JSON, not the type you want to end up with after transformation.
JSONAPIDocument
The entirety of a JSON API request or response is encoded or decoded from- or to a JSONAPIDocument. As an example, a JSON API response containing one Person and no included entities could be decoded as follows:
let decoder = JSONDecoder()
let responseStructure = JSONAPIDocument<SingleResourceBody<Person>, NoIncludes, BasicJSONAPIError>.self
let document = try decoder.decode(responseStructure, from: data)
ResourceBody
The first generic type of a JSONAPIDocument is a ResourceBody. This can either be a SingleResourceBody or a ManyResourceBody. You will find zero or one Entity values in a JSON API document that has a SingleResourceBody and you will find zero or more Entity values in a JSON API document that has a ManyResourceBody.
IncludeDecoder
The second generic type of a JSONAPIDocument is an IncludeDecoder. This type controls which types of Entity are looked for when decoding the "included" part of the JSON API document. If you do not expect any included entities to be in the document, NoIncludes is the way to go. The JSONAPI framework provides IncludeDecoders for up to six types of included entities. These are named Include1, Include2, Include3, and so on.
IMPORTANT: The number trailing "Include" in these type names does not indicate a number of included entities, it indicates a number of types of included entities. Include1 can be used to decode any number of included entities as long as all the entities are of the same type.
To specify that we expect friends of a person to be included in the above example JSONAPIDocument, we would use Include1<Person> instead of NoIncludes.
Error
The final generic type of a JSONAPIDocument is the Error. You should create an error type that can decode all the errors you expect your JSONAPIDocument to be able to decode. As prescribed by the JSON API Spec, these errors will be found in the root document member errors.
RawIdType
If you want to create new JSONAPI.Entity values and assign them Ids then you will need to conform at least one type to CreatableRawIdType. Doing so is easy; here are two example conformances for UUID and String (via UUID):
extension UUID: CreatableRawIdType {
public static func unique() -> UUID {
return UUID()
}
}
extension String: CreatableRawIdType {
public static func unique() -> String {
return UUID().uuidString
}
}