Skip to content

Commit

Permalink
Merge pull request #79 from mattpolzin/feature/documentation-and-exam…
Browse files Browse the repository at this point in the history
…ples

Feature/documentation and examples
  • Loading branch information
mattpolzin authored May 31, 2020
2 parents 5b0a016 + b1780d6 commit c703bff
Show file tree
Hide file tree
Showing 5 changed files with 364 additions and 284 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -72,50 +72,62 @@ typealias Article = JSONEntity<ArticleDescription>

// We create a typealias to represent a document containing one Article
// and including its Author
typealias SingleArticleDocumentWithIncludes = Document<SingleResourceBody<Article>, Include1<Author>>
typealias SingleArticleDocument = 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>
// ... and a typealias to represent a batch document containing any number of Articles
typealias ManyArticleDocument = Document<ManyResourceBody<Article>, Include1<Author>>

// MARK: - Server Pseudo-example

// 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> {
func article(includeAuthor: Bool) -> CompoundResource<Article, SingleArticleDocument.Include> {
// Let's pretend all of this is coming from a database:

let authorId = Author.Id(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 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 authorInclude: SingleArticleDocument.Include?
if includeAuthor {
let author = Author(
id: authorId,
attributes: .init(name: .init(value: "Janice Bluff")),
relationships: .none,
meta: .none,
links: .none
)
authorInclude = .init(author)
} else {
authorInclude = nil
}

return CompoundResource(
primary: article,
relatives: authorInclude.map { [$0] } ?? []
)
}

func articleDocument(includeAuthor: Bool) -> SingleArticleDocument {

let compoundResource = article(includeAuthor: includeAuthor)

return SingleArticleDocument(
apiDescription: .none,
resource: compoundResource,
meta: .none,
links: .none
)
}

let encoder = JSONEncoder()
Expand Down Expand Up @@ -151,7 +163,7 @@ func docode(articleResponseData: Data) throws -> (article: Article, author: Auth
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

let articleDocument = try decoder.decode(SingleArticleDocumentWithIncludes.self, from: articleResponseData)
let articleDocument = try decoder.decode(SingleArticleDocument.self, from: articleResponseData)

switch articleDocument.body {
case .data(let data):
Expand Down
246 changes: 11 additions & 235 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,24 @@ A Swift package for encoding to- and decoding from **JSON API** compliant reques

See the JSON API Spec here: https://jsonapi.org/format/

:warning: This library provides well-tested type safety when working with JSON:API 1.0. However, the Swift compiler can sometimes have difficulty tracking down small typos when initializing `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.

## Quick Start

:warning: The following Google Colab examples have correct code, but from time to time the Google Colab Swift compiler may be buggy and produce incorrect or erroneous results. Just keep that in mind if you run the code as you read through the Colab examples.
:warning: The following Google Colab examples have correct code, but from time to time the Google Colab Swift compiler may be buggy and claim it cannot build the JSONAPI library.

### Clientside
- [Basic Example](https://colab.research.google.com/drive/1IS7lRSBGoiW02Vd1nN_rfdDbZvTDj6Te)
- [Compound Example](https://colab.research.google.com/drive/1BdF0Kc7l2ixDfBZEL16FY6palweDszQU)
- [Metadata Example](https://colab.research.google.com/drive/10dEESwiE9I3YoyfzVeOVwOKUTEgLT3qr)
- [Custom Errors Example](https://colab.research.google.com/drive/1TIv6STzlHrkTf_-9Eu8sv8NoaxhZcFZH)
- [PATCH Example](https://colab.research.google.com/drive/16KY-0BoLQKiSUh9G7nYmHzB8b2vhXA2U)
- [Resource Storage Example](https://colab.research.google.com/drive/196eCnBlf2xz8pT4lW--ur6eWSVAjpF6b?usp=sharing)

### Serverside
- [GET Example](https://colab.research.google.com/drive/1krbhzSfz8mwkBTQQnKUZJLEtYsJKSfYX)
- [POST Example](https://colab.research.google.com/drive/1z3n70LwRY7vLIgbsMghvnfHA67QiuqpQ)

### Client+Server
This library works well when used by both the server responsible for serialization and the client responsible for deserialization. Check out the [example](#example) further down in this README.
This library works well when used by both the server responsible for serialization and the client responsible for deserialization. Check out the [example](./documentation/client-server-example.md).

## Table of Contents
- JSONAPI
Expand All @@ -34,8 +33,8 @@ This library works well when used by both the server responsible for serializati
- [Xcode project](#xcode-project)
- [CocoaPods](#cocoapods)
- [Running the Playground](#running-the-playground)
- [Project Status](#project-status)
- [Example](#example)
- [Project Status](./documentation/project-status.md)
- [Server & Client Example](./documentation/client-server-example.md)
- [Usage](./documentation/usage.md)
- [JSONAPI+Testing](#jsonapitesting)
- [Literal Expressibility](#literal-expressibility)
Expand Down Expand Up @@ -67,14 +66,11 @@ If you find something wrong with this library and it isn't already mentioned und
### Swift Package Manager
Just include the following in your package's dependencies and add `JSONAPI` to the dependencies for any of your targets.
```swift
.package(url: "https://github.com/mattpolzin/JSONAPI", from: "4.0.0-alpha.1")
.package(url: "https://github.com/mattpolzin/JSONAPI", from: "4.0.0")
```

### Xcode project
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.
With Xcode 11+, you can open the folder containing this repository. There is no need for an Xcode project, but you can generate one with `swift package generate-xcodeproj`.

### CocoaPods
To use this framework in your project via Cocoapods, add the following dependencies to your Podfile.
Expand All @@ -86,232 +82,12 @@ pod 'MP-JSONAPI', :git => 'https://github.com/mattpolzin/JSONAPI.git'
### Running the Playground
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.

## Project Status

### JSON:API
#### Document
- [x] `data`
- [x] `included`
- [x] `errors`
- [x] `meta`
- [x] `jsonapi` (i.e. API Information)
- [x] `links`

#### Resource Object
- [x] `id`
- [x] `type`
- [x] `attributes`
- [x] `relationships`
- [x] `links`
- [x] `meta`

#### Relationship Object
- [x] `data`
- [x] `links`
- [x] `meta`

#### Links Object
- [x] `href`
- [x] `meta`

### Misc
- [x] Support transforms on `Attributes` values (e.g. to support different representations of `Date`)
- [x] Support validation on `Attributes`.
- [x] 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.

### Testing
#### Resource Object 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.
- [x] Only allow `MetaRelationship`, `ToManyRelationship` and `ToOneRelationship` within `Relationships` struct.

### Potential Improvements
These ideas could be implemented in future versions.

- [ ] (Maybe) Use `KeyPath` to specify `Includes` thus creating type safety around the relationship between a primary resource type and the types of included resources.
- [ ] (Maybe) Replace `SingleResourceBody` and `ManyResourceBody` with support at the `Document` level to just interpret `PrimaryResource`, `PrimaryResource?`, or `[PrimaryResource]` as the same decoding/encoding strategies.
- [ ] Support sideposting. JSONAPI spec might become opinionated in the future (https://github.com/json-api/json-api/pull/1197, https://github.com/json-api/json-api/issues/1215, https://github.com/json-api/json-api/issues/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).

## Example
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.

### Preamble (Setup shared by server and client)
```swift
// 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 Ids.
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: JSONAPIIdentifiable> = 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>
```

### Server Pseudo-example
```swift
// 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.Id(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)!)
```

### Client Pseudo-example
```swift
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)
```
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.

## Deeper Dive
See the [usage documentation](./documentation/usage.md).
- [Project Status](./documentation/project-status.md)
- [Server & Client Example](./documentation/client-server-example.md)
- [Usage Documentation](./documentation/usage.md)

# JSONAPI+Testing
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.
Expand Down
Loading

0 comments on commit c703bff

Please sign in to comment.