From 8cea47ba6cf0d08cf033c357ec0f6128dc0e57f1 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 28 Apr 2025 09:27:43 -0500 Subject: [PATCH] switch from google colab examples to markdown --- README.md | 24 +-- documentation/examples/basic-example.md | 174 +++++++++++++++ .../{ => examples}/client-server-example.md | 0 documentation/examples/compound-example.md | 134 ++++++++++++ .../examples/custom-errors-example.md | 153 +++++++++++++ documentation/examples/metadata-example.md | 129 +++++++++++ documentation/examples/patch-example.md | 157 ++++++++++++++ .../examples/resource-storage-example.md | 204 ++++++++++++++++++ .../examples/serverside-get-example.md | 179 +++++++++++++++ .../examples/serverside-post-example.md | 152 +++++++++++++ 10 files changed, 1293 insertions(+), 13 deletions(-) create mode 100644 documentation/examples/basic-example.md rename documentation/{ => examples}/client-server-example.md (100%) create mode 100644 documentation/examples/compound-example.md create mode 100644 documentation/examples/custom-errors-example.md create mode 100644 documentation/examples/metadata-example.md create mode 100644 documentation/examples/patch-example.md create mode 100644 documentation/examples/resource-storage-example.md create mode 100644 documentation/examples/serverside-get-example.md create mode 100644 documentation/examples/serverside-post-example.md diff --git a/README.md b/README.md index c42c9d7..3fe2993 100644 --- a/README.md +++ b/README.md @@ -7,22 +7,20 @@ See the JSON API Spec here: https://jsonapi.org/format/ ## 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 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) (using [JSONAPI-ResourceStorage](#jsonapi-resourcestorage)) +- [Basic Example](./documentation/examples/basic-example.md) +- [Compound Example](./documentation/examples/compound-example.md) +- [Metadata Example](./documentation/examples/metadata-example.md) +- [Custom Errors Example](./documentation/examples/custom-errors-example.md) +- [PATCH Example](./documentation/examples/patch-example.md) +- [Resource Storage Example](./documentation/examples/resource-storage-example.md) (using [JSONAPI-ResourceStorage](#jsonapi-resourcestorage)) ### Serverside -- [GET Example](https://colab.research.google.com/drive/1krbhzSfz8mwkBTQQnKUZJLEtYsJKSfYX) -- [POST Example](https://colab.research.google.com/drive/1z3n70LwRY7vLIgbsMghvnfHA67QiuqpQ) +- [GET Example](./documentation/examples/serverside-get-example.md) +- [POST Example](./documentation/examples/serverside-post-example.md) ### 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](./documentation/client-server-example.md). +This library works well when used by both the server responsible for serialization and the client responsible for deserialization. Check out the [example](./documentation/examples/client-server-example.md). ## Table of Contents - JSONAPI @@ -34,7 +32,7 @@ This library works well when used by both the server responsible for serializati - [CocoaPods](#cocoapods) - [Running the Playground](#running-the-playground) - [Project Status](./documentation/project-status.md) - - [Server & Client Example](./documentation/client-server-example.md) + - [Server & Client Example](./documentation/examples/client-server-example.md) - [Usage](./documentation/usage.md) - [JSONAPI+Testing](#jsonapitesting) - [Literal Expressibility](#literal-expressibility) @@ -91,7 +89,7 @@ Note that Playground support for importing non-system Frameworks is still a bit ## Deeper Dive - [Project Status](./documentation/project-status.md) -- [Server & Client Example](./documentation/client-server-example.md) +- [Server & Client Example](./documentation/examples/client-server-example.md) - [Usage Documentation](./documentation/usage.md) # JSONAPI+Testing diff --git a/documentation/examples/basic-example.md b/documentation/examples/basic-example.md new file mode 100644 index 0000000..2b1e9fb --- /dev/null +++ b/documentation/examples/basic-example.md @@ -0,0 +1,174 @@ + +# JSONAPI Basic Example + +We are about to walk through a basic example to show how easy it is to set up a +simple model. Information on creating models that take advantage of more of the +features from the JSON:API Specification can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +The `JSONAPI` framework relies heavily on generic types so the first step will +be to alias away some of the JSON:API features we do not need for our simple +example. + +```swift +/// Our Resource objects will not have any metadata or links and they will be identified by Strings. +typealias Resource = JSONAPI.ResourceObject + +/// Our JSON:API Documents will similarly have no metadata or links associated with them. Additionally, there will be no included resources. +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> + +typealias BatchDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> +``` + +The next step is to create `ResourceObjectDescriptions` and `ResourceObjects`. +For our simple example, let's create a `Person` and a `Dog`. + +```swift +struct PersonDescription: ResourceObjectDescription { + // by common convention, we will use the plural form + // of the noun as the JSON:API "type" + static let jsonType: String = "people" + + struct Attributes: JSONAPI.Attributes { + let firstName: Attribute + let lastName: Attribute + + // we mark this attribute as "nullable" because the user can choose + // not to specify an age if they would like to. + let age: Attribute + } + + struct Relationships: JSONAPI.Relationships { + // we will define "Dog" next + let pets: ToManyRelationship + } +} + +// this typealias is optional, but it makes working with resource objects much +// more user friendly. +typealias Person = Resource + +struct DogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + } + + // we could relate dogs back to their owners, but for the sake of this example + // we will instead show how you would create a resource with no relationships. + typealias Relationships = NoRelationships +} + +typealias Dog = Resource +``` + +At this point we have two objects that can decode JSON:API responses. To +illustrate we can mock up a dog response and parse it. + +```swift +// snag Foundation for JSONDecoder +import Foundation + +let mockBatchDogResponse = +""" +{ + "data": [ + { + "type": "dogs", + "id": "123", + "attributes": { + "name": "Sparky" + } + }, + { + "type": "dogs", + "id": "456", + "attributes": { + "name": "Charlie Dog" + } + } + ] +} +""".data(using: .utf8)! + +let decoder = JSONDecoder() + +let dogsDocument = try! decoder.decode(BatchDocument.self, from: mockBatchDogResponse) + +let dogs = dogsDocument.body.primaryResource!.values + +print("dogs parsed: \(dogs.count ?? 0)") +``` + +To illustrate `ResourceObject` property access, we can loop over the dogs and +print their names. + +```swift +for dog in dogs { + print(dog.name) +} +``` + +Now let's parse a mocked `Person` response. + +```swift +let mockSinglePersonResponse = +""" +{ + "data": { + "type": "people", + "id": "88223", + "attributes": { + "first_name": "Lisa", + "last_name": "Offenbrook", + "age": null + }, + "relationships": { + "pets": { + "data": [ + { + "type": "dogs", + "id": "123" + }, + { + "type": "dogs", + "id": "456" + } + ] + } + } + } +} +""".data(using: .utf8)! + +decoder.keyDecodingStrategy = .convertFromSnakeCase + +let personDocument = try! decoder.decode(SingleDocument.self, from: mockSinglePersonResponse) +``` + +Our `Person` object has both attributes and relationships. Generally what we care +about when accessing relationships is actually the Id(s) of the resource(s); the +loop below shows off how to access those Ids. + +```swift +let person = personDocument.body.primaryResource!.value + +let relatedDogIds = person ~> \.pets + +print("related dog Ids: \(relatedDogIds)") +``` + +To wrap things up, let's throw our dog resources into a local cache and tie +things together a bit. There are many ways to go about storing or caching +resources clientside. For additional examples of more robust solutions, take a +look at [JSONAPI-ResourceStorage](https://github.com/mattpolzin/JSONAPI-ResourceStorage). + +```swift +let dogCache = Dictionary(uniqueKeysWithValues: zip(dogs.map { $0.id }, dogs)) + +func cachedDog(_ id: Dog.Id) -> Dog? { return dogCache[id] } + +print("Our person's name is \(person.firstName) \(person.lastName).") +print("They have \((person ~> \.pets).count) pets named \((person ~> \.pets).compactMap(cachedDog).map { $0.name }.joined(separator: " and ")).") +``` + diff --git a/documentation/client-server-example.md b/documentation/examples/client-server-example.md similarity index 100% rename from documentation/client-server-example.md rename to documentation/examples/client-server-example.md diff --git a/documentation/examples/compound-example.md b/documentation/examples/compound-example.md new file mode 100644 index 0000000..18988dd --- /dev/null +++ b/documentation/examples/compound-example.md @@ -0,0 +1,134 @@ +# JSONAPI Compound Example + +We are about to walk through an example to show how easy it is to parse JSON:API +includes. Information on creating models that take advantage of more of the +features from the JSON:API Specification can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +We will begin by quickly redefining the same types of `ResourceObjects` from the +[Basic Example](https://github.com/mattpolzin/JSONAPI/blob/main/documentation/basic-example.md). + +```swift +typealias Resource = JSONAPI.ResourceObject + +struct PersonDescription: ResourceObjectDescription { + + static let jsonType: String = "people" + + struct Attributes: JSONAPI.Attributes { + let firstName: Attribute + let lastName: Attribute + + let age: Attribute + } + + struct Relationships: JSONAPI.Relationships { + let pets: ToManyRelationship + } +} + +typealias Person = Resource + +struct DogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + } + + typealias Relationships = NoRelationships +} + +typealias Dog = Resource +``` + +Next we will create similar `typealiases` for single and batch documents as we did +in the **Basic Example**, but we will allow for an include type to be specified. + +```swift +/// Our JSON:API Documents will still have no metadata or links associated with them but they will allow us to specify an include type later. +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, Include, NoAPIDescription, UnknownJSONAPIError> + +typealias BatchDocument = JSONAPI.Document, NoMetadata, NoLinks, Include, NoAPIDescription, UnknownJSONAPIError> +``` + +Now let's define a mock response containing a single person and including any +dogs that are related to that person. + +```swift +// snag Foundation for Data and JSONDecoder +import Foundation + +let mockSinglePersonResponse = +""" +{ + "data": { + "type": "people", + "id": "88223", + "attributes": { + "first_name": "Lisa", + "last_name": "Offenbrook", + "age": null + }, + "relationships": { + "pets": { + "data": [ + { + "type": "dogs", + "id": "123" + }, + { + "type": "dogs", + "id": "456" + } + ] + } + } + }, + "included": [ + { + "type": "dogs", + "id": "123", + "attributes": { + "name": "Sparky" + } + }, + { + "type": "dogs", + "id": "456", + "attributes": { + "name": "Charlie Dog" + } + } + ] +} +""".data(using: .utf8)! +``` + +Parsing the above response looks almost identical to in the **Basic Example**. The +key thing to note is that instead of specifying `NoIncludes` we specify +`Include1` below. This does not mean "include one dog," it means "include one +type of thing, with that type being `Dog`." The `JSONAPI` framework comes with +built-in support for `Include2<...>`, `Include3<...>` and many more. If you wanted to include +both `Person` and `Dog` resources (perhaps because your primary `Person` resource had +a "friends" relationship), you would use `Include2`. + +```swift +let decoder = JSONDecoder() +decoder.keyDecodingStrategy = .convertFromSnakeCase + +let includeDocument = try! decoder.decode(SingleDocument>.self, from: mockSinglePersonResponse) +``` + +The `Person` is pulled out as before with `Document.body.primaryResource`. The dogs +can be accessed from `Document.body.includes`; note that because multiple types of +things can be included, we must specify that we want things of type `Dog` by using +the `JSONAPI.Includes` subscript operator. + +```swift +let person = includeDocument.body.primaryResource!.value +let dogs = includeDocument.body.includes![Dog.self] + +print("Parsed person named \(person.firstName) \(person.lastName)") +print("Parsed dogs named \(dogs.map { $0.name }.joined(separator: " and "))") +``` + diff --git a/documentation/examples/custom-errors-example.md b/documentation/examples/custom-errors-example.md new file mode 100644 index 0000000..c575ed3 --- /dev/null +++ b/documentation/examples/custom-errors-example.md @@ -0,0 +1,153 @@ + +# JSONAPI Custom Errors Example + +We are about to walk through an example of parsing a JSON:API errors response. +Information on creating models that take advantage of more of the features from +the JSON:API Specification can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +First we will define a structure that can parse each of the errors we might +expect to get back from the server. This is the one type for which the framework +does not offer a generic option but we can pretty easily pick the relevant +properties from the **Error Object** description given by JSON:API +[here](https://www.google.com/url?q=https%3A%2F%2Fjsonapi.org%2Fformat%2F%23error-objects). +We will choose only to distinguish between server and client errors for this +example but that is the tip of the iceberg if you wish to make more robust error +handling for yourself. + +```swift +enum OurExampleError: JSONAPIError { + case unknownError + case server(code: Int, description: String) + case client(code: Int, description: String) + + static var unknown: OurExampleError { return .unknownError } + + // Example decoder just switches on the status code + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let code = try Int(container.decode(String.self, forKey: .status)) + + guard let statusCode = code else { + throw DecodingError.typeMismatch(Int.self, + .init(codingPath: decoder.codingPath, + debugDescription: "Expected an integer HTTP status code.")) + } + + switch statusCode { + case 400..<500: + self = try .client(code: statusCode, description: container.decode(String.self, forKey: .detail)) + case 500..<600: + self = try .server(code: statusCode, description: container.decode(String.self, forKey: .detail)) + default: + self = .unknown + } + } + + // naturally, opposite of decoding except for needing to put something down + // for the unknown case. We choose 500 here; client won't need to encode errors + // and 500 is fitting for this situation on the server side. + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + let status: String + let detail: String + switch self { + case .server(let code, let description), + .client(let code, let description): + status = String(code) + detail = description + default: + status = "500" + detail = "Unknown problem occurred" + } + + try container.encode(status, forKey: .status) + try container.encode(detail, forKey: .detail) + } + + private enum CodingKeys: String, CodingKey { + case status + case detail + } +} +``` + +Next we will define some utility `typealiases` like we did in the +[Basic Example](https://github.com/mattpolzin/JSONAPI/blob/main/documentation/basic-example.md). +This time, we will specify that our `Document` type expects to parse +`OurExampleError`. + +```swift +typealias Resource = JSONAPI.ResourceObject + +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, OurExampleError> +``` + +We will reuse the `Dog` type from the **Basic Example**. We won't actually be +parsing this type because we are showing off error parsing. + +```swift +struct DogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + } + + typealias Relationships = NoRelationships +} + +typealias Dog = Resource +``` + +Now let's mock up an error response. + +```swift +// snag Foundation for Data and JSONDecoder +import Foundation + +let mockErrorResponse = +""" +{ + "errors": [ + { + "status": "400", + "detail": "You made a bad request" + }, + { + "status": "500", + "detail": "The server fell over because it tried to handle your bad request" + } + ] +} +""".data(using: .utf8)! +``` + +Now we can parse the response data and switch on the response body to see if we +are dealing with an error or successful request (although we know in this case +it will be an error, of course). + +```swift +let decoder = JSONDecoder() + +let dogDocument = try! decoder.decode(SingleDocument.self, from: mockErrorResponse) + +switch dogDocument.body { +case .data(let response): + print("this would be unexpected given our mock data!") + +case .errors(let errors, meta: _, links: _): + print("The server returned the following errors:") + print(errors.map { error -> String in + switch error { + case .client(let code, let description), + .server(let code, let description): + return "\(code): \(description)" + default: + return "unknown" + } + }) +} +``` + diff --git a/documentation/examples/metadata-example.md b/documentation/examples/metadata-example.md new file mode 100644 index 0000000..d1b7ca6 --- /dev/null +++ b/documentation/examples/metadata-example.md @@ -0,0 +1,129 @@ + +# JSONAPI Metadata Example + +We are about to walk through an example of parsing JSON:API metadata. +Information on creating models that take advantage of more of the features from +the JSON:API Specification can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +We will begin by quickly redefining the same types of `ResourceObjects` from the +[Basic Example](https://github.com/mattpolzin/JSONAPI/blob/main/documentation/basic-example.md). + +```swift +typealias Resource = JSONAPI.ResourceObject + +struct PersonDescription: ResourceObjectDescription { + + static let jsonType: String = "people" + + struct Attributes: JSONAPI.Attributes { + let firstName: Attribute + let lastName: Attribute + + let age: Attribute + } + + struct Relationships: JSONAPI.Relationships { + let pets: ToManyRelationship + } +} + +typealias Person = Resource + +struct DogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + } + + typealias Relationships = NoRelationships +} + +typealias Dog = Resource +``` + +We will additionally define a structure that can parse some pagination metadata. + +```swift +struct PaginationMetadata: JSONAPI.Meta { + + let page: Page + + /// The total count of all resources of the primary type of a given response. + let total: Int + + struct Page: Codable, Equatable { + let index: Int + let size: Int + } +} +``` + +Next we will create similar `typealiases` for single and batch documents as we did +in the **Basic Example**, but we will specify that we expect the `BatchDocument` to +include our `PaginationMetadata`. + +```swift +/// Our JSON:API Documents will still have no metadata or links associated with them but they will allow us to specify an include type later. +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> + +typealias BatchDocument = JSONAPI.Document, PaginationMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> +``` + +Now let's define a mock response containing a batch of dogs and pagination +metadata. + +```swift +// snag Foundation for Data and JSONDecoder +import Foundation + +let mockBatchDogResponse = +""" +{ + "data": [ + { + "type": "dogs", + "id": "123", + "attributes": { + "name": "Sparky" + } + }, + { + "type": "dogs", + "id": "456", + "attributes": { + "name": "Charlie Dog" + } + } + ], + "meta": { + "total": 10, + "page": { + "index": 2, + "size": 2 + } + } +} +""".data(using: .utf8)! +``` + +Parsing the above response looks identical to in the **Basic Example**. + +```swift +let decoder = JSONDecoder() +decoder.keyDecodingStrategy = .convertFromSnakeCase + +let metadataDocument = try! decoder.decode(BatchDocument.self, from: mockBatchDogResponse) +``` + +The `Dogs` are pulled out as before with `Document.body.primaryResource`. The +metadata is accessed by the `Document.body.metadata` property. + +```swift +let dogs = metadataDocument.body.primaryResource!.values +let metadata = metadataDocument.body.meta! + +print("Parsed dogs named \(dogs.map { $0.name }.joined(separator: " and "))") +print("Page \(metadata.page.index) out of \(metadata.total / metadata.page.size) at \(metadata.page.size) resources per page.") +``` + diff --git a/documentation/examples/patch-example.md b/documentation/examples/patch-example.md new file mode 100644 index 0000000..d7a8629 --- /dev/null +++ b/documentation/examples/patch-example.md @@ -0,0 +1,157 @@ + +# JSONAPI PATCH Example + +We are about to walk through an example to show how to take an existing resource +object and create a copy with different attributes. Additional information on +the features used here can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +The `JSONAPI` framework relies heavily on generic types so the first step will be +to alias away some of the JSON:API features we do not need for our simple +example. + +```swift +/// Our Resource objects will not have any metadata or links and they will be identified by Strings. +typealias Resource = JSONAPI.ResourceObject + +/// Our JSON:API Documents will similarly have no metadata or links associated with them. Additionally, there will be no included resources. +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> +``` + +The next step is to create `ResourceObjectDescriptions` and `ResourceObjects`. For +our simple example, let's create a `Dog`. We will choose to make the properties of +our `Attributes` struct `vars` to facilitate updating them via the `ResourceObject` +`tapping` functions; an alternative approach with an immutable structure is found +later in this tutorial. + +```swift +struct DogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + var name: Attribute + } + + typealias Relationships = NoRelationships +} + +typealias Dog = Resource +``` + +At this point we have two objects that can decode JSON:API responses. To +illustrate we can mock up a dog response we might receive from the server and +parse it. + +```swift +// snag Foundation for JSONDecoder +import Foundation + +let mockDogResponse = +""" +{ + "data": { + "type": "dogs", + "id": "123", + "attributes": { + "name": "Sparky" + } + } +} +""".data(using: .utf8)! + +let decoder = JSONDecoder() + +let dogDocument = try! decoder.decode(SingleDocument.self, from: mockDogResponse) + +let dog = dogDocument.body.primaryResource!.value +``` + +We'll demonstrate renaming the dog using `ResourceObject`'s `tappingAttributes()` +function. + +```swift +let updatedDog = dog + .tappingAttributes { $0.name = .init(value: "Charlie") } +``` + +Now we can prepare a document to be used as the request body of a `PATCH` request. + +```swift +let patchRequestDocument = SingleDocument(apiDescription: .none, + body: .init(resourceObject: updatedDog), + includes: .none, + meta: .none, + links: .none) + +let requestBody = JSONEncoder().encode(patchRequestDocument) +``` + +Instead of actually sending off a `PATCH` request, we will just print the request +body out to prove to ourselves that the name was updated. + +```swift +print(String(data: requestBody, encoding: .utf8)!) +``` + +---- + +Alternatively, the `Attributes` struct could have been defined with `let` +properties. Not much changes, but the `name` cannot be mutated so the entire +struct must be recreated. We will take this opportunity to use the +`ResourceObject` `replacingAttributes()` method to contrast it to the +`tappingAttributes()` method. + +The `ImmutableDogDescription` below is almost identical to `DogDescription`, but the `name` +is a `let` constant. + +```swift +struct ImmutableDogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + } + + typealias Relationships = NoRelationships +} + +typealias Dog2 = Resource +``` + +We can use the same mock data for a single dog document and parse it as a `Dog2`. + +```swift +let dogDocument2 = try! decoder.decode(SingleDocument.self, from: mockDogResponse) + +let dog2 = dogDocument2.body.primaryResource!.value +``` + +We could still use `tappingAttributes()` but we cannot mutate the name property of +the new `Attributes` struct, so we will use `replacingAttributes()` instead. This +method takes as its parameter a function that returns the new attributes. + +```swift +let updatedDog2 = dog2 + .replacingAttributes { _ in + return .init(name: .init(value: "Toby")) +} +``` + +Now create a request document. + +```swift +let patchRequestDocument2 = SingleDocument(apiDescription: .none, + body: .init(resourceObject: updatedDog2), + includes: .none, + meta: .none, + links: .none) + +let requestBody2 = JSONEncoder().encode(patchRequestDocument2) +``` + +Once again, we'll print the request body out instead of sending it with a `PATCH` +request. + +```swift +print(String(data: requestBody2, encoding: .utf8)!) +``` + diff --git a/documentation/examples/resource-storage-example.md b/documentation/examples/resource-storage-example.md new file mode 100644 index 0000000..4e63ae7 --- /dev/null +++ b/documentation/examples/resource-storage-example.md @@ -0,0 +1,204 @@ + +# JSONAPI Resource Storage Example + +We are about to walk through an example to show one possible way to handle +resource caching on the clientside. This example depends on both +[JSONAPI](https://github.com/mattpolzin/JSONAPI) and +[JSONAPI-ResourceStorage](https://github.com/mattpolzin/JSONAPI-ResourceStorage). + +Information on creating models that take advantage of more of the features from +the JSON:API Specification can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +We will begin by quickly redefining the same types of `ResourceObjects` from the +[Basic Example](https://github.com/mattpolzin/JSONAPI/blob/main/documentation/basic-example.md). + +```swift +typealias Resource = JSONAPI.ResourceObject + +struct PersonDescription: ResourceObjectDescription { + + static let jsonType: String = "people" + + struct Attributes: JSONAPI.Attributes { + let firstName: Attribute + let lastName: Attribute + + /// User is not required to specify their age. + let age: Attribute + } + + struct Relationships: JSONAPI.Relationships { + let pets: ToManyRelationship + } +} + +typealias Person = Resource + +struct DogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + } + + typealias Relationships = NoRelationships +} + +typealias Dog = Resource +``` + +We can borrow the `Document` `typealiases` from the +[Compound Example](https://github.com/mattpolzin/JSONAPI/blob/main/documentation/compound-example.md). + +```swift +/// Our JSON:API Documents will still have no metadata or links associated with them but they will allow us to specify an include type later. +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, Include, NoAPIDescription, UnknownJSONAPIError> + +typealias BatchDocument = JSONAPI.Document, NoMetadata, NoLinks, Include, NoAPIDescription, UnknownJSONAPIError> +``` + +We define a resource cache capable of storing `Person` and `Dog` types. As a +convenience, we define what it means to merge two `Caches`. The `merge` method is +not a requirement of `ResourceCache` but it will allow us to easily add resources +from our JSON:API document to our cache. + +We are going to use a value type for the cache. A reference type (like the one +in the `JSONAPIResourceStore` module in this package) could also be used, but an +equatable value type works well when you want your app state to be comparable so +your logic can determine when the cache has changed. + +```swift +struct Cache: Equatable, ResourceCache { + var people: ResourceHash = [:] + var dogs: ResourceHash = [:] + + mutating func merge(_ other: Cache) { + // we merge and resolve conflicts with `other`'s versions so we effectively + // "add or update" each resource. + people.merge(other.people, uniquingKeysWith: { $1 }) + dogs.merge(other.dogs, uniquingKeysWith: { $1 }) + } +} +``` + +We need to tell people and dogs where to find themselves in the cache. + +```swift +extension PersonDescription: Materializable { + public static var cachePath: WritableKeyPath> { \.people } +} + +extension DogDescription: Materializable { + public static var cachePath: WritableKeyPath> { \.dogs } +} +``` + +Let's create our app-wide cache of resources. We are going to use a value type; +a reference type could be used just as well, but a value type that is equatable. + +```swift +var cache = Cache() +``` + +Now let's define a mock response containing a single person and including any +dogs that are related to that person. + +```swift +let mockSinglePersonResponse = +""" +{ + "data": { + "type": "people", + "id": "88223", + "attributes": { + "first_name": "Lisa", + "last_name": "Offenbrook", + "age": null + }, + "relationships": { + "pets": { + "data": [ + { + "type": "dogs", + "id": "123" + }, + { + "type": "dogs", + "id": "456" + } + ] + } + } + }, + "included": [ + { + "type": "dogs", + "id": "123", + "attributes": { + "name": "Sparky" + } + }, + { + "type": "dogs", + "id": "456", + "attributes": { + "name": "Charlie Dog" + } + } + ] +} +""".data(using: .utf8)! +``` + +We decode a document like the one mocked above as a `SingleDocument` specialized +on a primary resource type of `Person` and an include type of `Include1` +(a.k.a. all included resources will be of the same type: `Dog`). + +```swift +let decoder = JSONDecoder() +decoder.keyDecodingStrategy = .convertFromSnakeCase + +let document = try decoder.decode(SingleDocument>.self, from: mockSinglePersonResponse) +``` + +We can ask `document` for a cache of resources it contains. Then we can merge that +into our app-wide cache. + +```swift +if let documentResources = document.resourceCache() { + cache.merge(documentResources) +} else { + // probably time to check for an error response. +} +``` + +We can access all people in the cache. + +```swift +for person in cache.people.values { + print("\(person.firstName) \(person.lastName) has \((person ~> \.pets).count) dogs.") +} +``` + +We can access those dogs via the cache using the cache's subscript operator. + +```swift +for person in cache.people.values { + print("\(person.firstName) \(person.lastName) has pets named:") + for dogId in (person ~> \.pets) { + print(cache[dogId]?.name ?? "missing dog info") + } +} +``` + +We can also map the dog ids to materialized dogs. + +```swift +for person in cache.people.values { + let dogs = (person ~> \.pets).compactMap { $0.materialized(from: cache) } + let dogNames = dogs.map(\.name).joined(separator: ", ") + + print("\(person.firstName) \(person.lastName) has pets named: \(dogNames)") +} +``` + diff --git a/documentation/examples/serverside-get-example.md b/documentation/examples/serverside-get-example.md new file mode 100644 index 0000000..a3dc390 --- /dev/null +++ b/documentation/examples/serverside-get-example.md @@ -0,0 +1,179 @@ + +# JSONAPI Serverside GET Example + +We are about to walk through a basic example of serializing a simple model. +Information on creating models that take advantage of more of the features from +the JSON:API Specification can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +Note that the first two steps here are almost identical to the first two steps +in the +[Clientside Basic Example](https://github.com/mattpolzin/JSONAPI/blob/main/documentation/basic-example.md). +The same Swift resource types you create with this framework can be used by the +client and the server. + +The `JSONAPI` framework relies heavily on generic types so the first step will be +to alias away some of the JSON:API features we do not need for our simple +example. + +```swift +/// Our Resource objects will not have any metadata or links and they will be identified by Strings. +typealias Resource = JSONAPI.ResourceObject + +/// Our JSON:API Documents will similarly have no metadata or links associated with them. Additionally, there will be no included resources. +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> + +typealias BatchDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> +``` + +The next step is to create `ResourceObjectDescriptions` and `ResourceObjects`. For +our simple example, let's create a `Person` and a `Dog`. + +```swift +enum API {} + +struct PersonDescription: ResourceObjectDescription { + // by common convention, we will use the plural form + // of the noun as the JSON:API "type" + static let jsonType: String = "people" + + struct Attributes: JSONAPI.Attributes { + let firstName: Attribute + let lastName: Attribute + + // we mark this attribute as "nullable" because the user can choose + // not to specify an age if they would like to. + let age: Attribute + } + + struct Relationships: JSONAPI.Relationships { + // we will define "Dog" next + let pets: ToManyRelationship + } +} + +// this typealias is optional, but it makes working with resource objects much +// more user friendly. +extension API { + typealias Person = Resource +} + +struct DogDescription: ResourceObjectDescription { + static let jsonType: String = "dogs" + + struct Attributes: JSONAPI.Attributes { + let name: Attribute + } + + // we could relate dogs back to their owners, but for the sake of this example + // we will instead show how you would create a resource with no relationships. + typealias Relationships = NoRelationships +} + +extension API { + typealias Dog = Resource +} +``` + +At this point we have two objects that can encode JSON:API responses. To +illustrate we will skip over the details of reading from the database and assume +we have some data ready to be turned into a JSON:API response for a collection +of `Dogs`. + +```swift +// snag Foundation for JSONDecoder +import Foundation + +// This is just a standin for whatever models you've got coming out of the database. +enum DB { + struct Dog { + let id: Int + let name: String + } +} + +// you could handle this any number of ways, but here we will write an extension +// that gets you the `JSONAPI` models from the database models. +extension DB.Dog { + var serializable: API.Dog { + let attributes = API.Dog.Attributes(name: .init(value: name)) + return API.Dog(id: .init(rawValue: String(id)), + attributes: attributes, + relationships: .none, + meta: .none, + links: .none) + } +} + +let dogs = [ + DB.Dog(id: 123, name: "Sparky"), + DB.Dog(id: 456, name: "Charlie Dog") +].map { $0.serializable } + + +let encoder = JSONEncoder() +encoder.outputFormatting = .prettyPrinted + +let primaryResources = ManyResourceBody(resourceObjects: dogs) + +let dogsDocument = BatchDocument(apiDescription: .none, + body: primaryResources, + includes: .none, + meta: .none, + links: .none) + +let dogsResponse = try! encoder.encode(dogsDocument) + +// At this point you can send the response data to the client in whatever way you like. + +print("dogs response: \(String(data: dogsResponse, encoding: .utf8)!)") +``` + +Let's look at a single `Person` response as well. + +```swift +extension DB { + struct Person { + let id: Int + let firstName: String + let lastName: String + let age: Int? + + /// relationship to dogs created as array of String Ids + let dogs: [Int] + } +} + +extension DB.Person { + var serializable: API.Person { + let attributes = API.Person.Attributes(firstName: .init(value: firstName), + lastName: .init(value: lastName), + age: .init(value: age)) + let relationships = API.Person.Relationships(pets: .init(ids: dogs.map { API.Dog.Id(rawValue: String($0)) })) + + return API.Person(id: .init(rawValue: String(id)), + attributes: attributes, + relationships: relationships, + meta: .none, + links: .none) + } +} + +let person = DB.Person(id: 9876, + firstName: "Julie", + lastName: "Stone", + age: nil, + dogs: [123, 456]).serializable + +let personDocument = SingleDocument(apiDescription: .none, + body: .init(resourceObject: person), + includes: .none, + meta: .none, + links: .none) + +let personResponse = try! encoder.encode(personDocument) + +// At this point you can send the response data to the client in whatever way you like. + +print("person response: \(String(data: personResponse, encoding: .utf8)!)") +``` + diff --git a/documentation/examples/serverside-post-example.md b/documentation/examples/serverside-post-example.md new file mode 100644 index 0000000..5be338f --- /dev/null +++ b/documentation/examples/serverside-post-example.md @@ -0,0 +1,152 @@ + +# JSONAPI Serverside POST Example + +We are about to walk through an example handling a POST (resource creation) +request. Information on creating models that take advantage of more of the +features from the JSON:API Specification can be found in the [README](https://github.com/mattpolzin/JSONAPI/blob/main/README.md). + +We will identify our resources using `UUID`s. + +```swift +// If we wanted to, we could just make `UUID` a `RawIdType` +// extension UUID: RawIdType {} + +// We will go a step further and make it a `CreatableRawIdType` and let `JSONAPI` +// create new unique Ids for people in the POST handling code farther down in this +// example. +extension UUID: CreatableRawIdType { + public static func unique() -> UUID { + return UUID() + } +} +``` + +The `JSONAPI` framework relies heavily on generic types so the first step will be +to alias away some of the JSON:API features we do not need for our simple +example. + +```swift +/// Our Resource objects will not have any metadata or links and they will be identified by UUIDs. +typealias Resource = JSONAPI.ResourceObject + +/// The client will send us a POST request with an unidenfitied resource object. We will call this a "new resource object" +typealias New = JSONAPI.ResourceObject + +/// Our JSON:API Documents will similarly have no metadata or links associated with them. Additionally, there will be no included resources. +typealias SingleDocument = JSONAPI.Document, NoMetadata, NoLinks, NoIncludes, NoAPIDescription, UnknownJSONAPIError> +``` + +The next step is to create `ResourceObjectDescriptions` and `ResourceObjects`. For +our simple example, we will handle a `POST` request for a `Person` resource. + +```swift +enum API {} + +struct PersonDescription: ResourceObjectDescription { + // by common convention, we will use the plural form + // of the noun as the JSON:API "type" + static let jsonType: String = "people" + + struct Attributes: JSONAPI.Attributes { + let firstName: Attribute + let lastName: Attribute + + // we mark this attribute as "nullable" because the user can choose + // not to specify an age if they would like to. + let age: Attribute + } + + typealias Relationships = NoRelationships +} + +// this typealias is optional, but it makes working with resource objects much +// more user friendly. +extension API { + typealias Person = Resource +} +``` + +To illustrate using the `JSONAPI` framework, we will skip over the details of +database reading/writing. Let's mock up a database model for a `Person`. + +```swift +// snag Foundation for JSONDecoder +import Foundation + +// This is just a standin for whatever models you've got coming out of the database. +enum DB { + struct Person { + let id: String + let firstName: String + let lastName: String + let age: Int? + } +} + +// you could handle this any number of ways, but here we will write an initializer +// that gets you a database model from the `JSONAPI` model. +extension DB.Person { + + init(_ person: API.Person) { + id = "\(person.id.rawValue)" + firstName = person.firstName + lastName = person.lastName + age = person.age + } +} +``` + +Now we'll handle a `POST` request by creating a new database record (we'll skip +this detail) and responding with a `Person` resource. + +```swift +// NOTE this request has no Id because the client is requesting this new `Person` be created. +let mockPersonRequest = +""" +{ + "data": { + "type": "people", + "attributes": { + "first_name": "Jimmie", + "last_name": "Glows", + "age": 53 + } + } +} +""".data(using: .utf8)! + +let decoder = JSONDecoder() +decoder.keyDecodingStrategy = .convertFromSnakeCase + +// We will decode a "new" resource (see typealiases earlier in this example) +let requestedPersonDocument = try! decoder.decode(SingleDocument>.self, from: mockPersonRequest) +let requestedPerson = requestedPersonDocument.body.primaryResource!.value + +// Our DB.Person initializer expects an identified `Person`, not a `New` +// but we can let the `JSONAPI` framework create a new `UUID` for us: +let identifiedPerson = requestedPerson.identified(byType: UUID.self) + +let dbPerson = DB.Person(identifiedPerson) + +// Here's where we would save our `dbPerson` to te database, if we had an +// actualy database connection in this example. We'd also create our response from +// the result of that database save, ideally. We are going to skip those details +// and pretend the database write was successful. + +// finally, let's create a response +let encoder = JSONEncoder() +encoder.keyEncodingStrategy = .convertToSnakeCase +encoder.outputFormatting = .prettyPrinted + +let responseData = try! encoder.encode(SingleDocument(apiDescription: .none, + body: .init(resourceObject: identifiedPerson), + includes: .none, + meta: .none, + links: .none)) + +// Send it off to the client! + +print("response body:") +print("\(String(data: responseData, encoding: .utf8)!)") +``` +