diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a50d0..9424ee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [2.1.0] 14-12-22 +- [104] Add support for partially decoding arrays through new `arrayDecodingStrategy` parameter on `Request`. +- [106] Fix `RetryConfiguration` not being marked as `Sendable`. + + ## [2.0.0] 18-11-22 - [77] Rework networking to use async/await by default. - [96] Add support + documentation for basic GraphQL requests. + ## [1.0.0] 12-9-21 ### Added - [60] Add better handling for printing request bodies to the console, including some default redaction for sensitive parameters. @@ -18,19 +24,23 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - [76] Add an option to set json en/decoding strategies in the Netable config constructor in addition to per-request. - [79] Add `requestFailedDelegate` and `requestFailedPublisher` to users to handle errors globally in addition to in `request` completion callbacks. Bumps minimum iOS version to 13.0. + ## [0.10.3] - 12-01-21 ### Changed - Fixed an issue with logging successful requests that was preventing finalized data from being printed properly. - Fixed a couple small tpyos. + ## [0.10.2] - 10-08-20 ### Added - Added support for `DELETE` requests. + ## [0.10.1] - 16-07-20 ### Changed - Fixed some properties in the new logging not being marked as "public". + ## [0.10.0] - 16-07-20 ### Added - Requests are now automatically retried for (some) failures. The new RetryConfiguration struct controls the exact mechanisms for retrying. diff --git a/Netable/Netable.xcodeproj/project.pbxproj b/Netable/Netable.xcodeproj/project.pbxproj index 64c1615..ace8371 100644 --- a/Netable/Netable.xcodeproj/project.pbxproj +++ b/Netable/Netable.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ C639674D28E4F4CD00ADAE3E /* Netable+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C639674C28E4F4CD00ADAE3E /* Netable+Equatable.swift */; }; C639674F28E4F58100ADAE3E /* String+FullyQualifiedURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = C639674E28E4F58100ADAE3E /* String+FullyQualifiedURL.swift */; }; C639675128E5F89D00ADAE3E /* Netable+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = C639675028E5F89D00ADAE3E /* Netable+Error.swift */; }; + C64ADA47293F9ED900695444 /* ArrayDecodeStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C64ADA46293F9ED900695444 /* ArrayDecodeStrategy.swift */; }; C64EAB6926F2828E0093850A /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = C64EAB6826F2828E0093850A /* Post.swift */; }; C64EAB6B26F283440093850A /* GetPostsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C64EAB6A26F283440093850A /* GetPostsRequest.swift */; }; C64EAB6D26F29AFD0093850A /* UnauthorizedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C64EAB6C26F29AFD0093850A /* UnauthorizedRequest.swift */; }; @@ -67,6 +68,7 @@ C692789D26F147FC00917E65 /* GetUserDetailsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C692789C26F147FC00917E65 /* GetUserDetailsRequest.swift */; }; C6953F42241A95830044D278 /* LogDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6953F41241A95830044D278 /* LogDestination.swift */; }; C6A455EE2912D77600C1C20E /* ErrorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A455ED2912D77600C1C20E /* ErrorService.swift */; }; + C6DA3354293822230076F693 /* LossyArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6DA3353293822230076F693 /* LossyArray.swift */; }; C6F4CFB026D582E8004E6BB8 /* RequestFailedDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F4CFAF26D582E8004E6BB8 /* RequestFailedDelegate.swift */; }; C6F4CFB626D598B3004E6BB8 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = C6F4CFB526D598B3004E6BB8 /* README.md */; }; /* End PBXBuildFile section */ @@ -136,6 +138,7 @@ C639674C28E4F4CD00ADAE3E /* Netable+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Netable+Equatable.swift"; sourceTree = ""; }; C639674E28E4F58100ADAE3E /* String+FullyQualifiedURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+FullyQualifiedURL.swift"; sourceTree = ""; }; C639675028E5F89D00ADAE3E /* Netable+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Netable+Error.swift"; sourceTree = ""; }; + C64ADA46293F9ED900695444 /* ArrayDecodeStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayDecodeStrategy.swift; sourceTree = ""; }; C64EAB6826F2828E0093850A /* Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; C64EAB6A26F283440093850A /* GetPostsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPostsRequest.swift; sourceTree = ""; }; C64EAB6C26F29AFD0093850A /* UnauthorizedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnauthorizedRequest.swift; sourceTree = ""; }; @@ -166,6 +169,7 @@ C692789C26F147FC00917E65 /* GetUserDetailsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetUserDetailsRequest.swift; sourceTree = ""; }; C6953F41241A95830044D278 /* LogDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogDestination.swift; sourceTree = ""; }; C6A455ED2912D77600C1C20E /* ErrorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorService.swift; sourceTree = ""; }; + C6DA3353293822230076F693 /* LossyArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LossyArray.swift; sourceTree = ""; }; C6F4CFAF26D582E8004E6BB8 /* RequestFailedDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestFailedDelegate.swift; sourceTree = ""; }; C6F4CFB526D598B3004E6BB8 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../../README.md; sourceTree = ""; }; /* End PBXFileReference section */ @@ -245,6 +249,7 @@ B8C928A223E9FBEC00DB2B37 /* HTTPMethod.swift */, B8C9288423E9F68000DB2B37 /* Info.plist */, C6953F41241A95830044D278 /* LogDestination.swift */, + C6DA3353293822230076F693 /* LossyArray.swift */, B8C9288323E9F68000DB2B37 /* Netable.h */, B8C928A823E9FDCC00DB2B37 /* Netable.swift */, C6F4CFB526D598B3004E6BB8 /* README.md */, @@ -282,9 +287,10 @@ C639674B28E4F4BF00ADAE3E /* Helper */ = { isa = PBXGroup; children = ( + C64ADA46293F9ED900695444 /* ArrayDecodeStrategy.swift */, C639674C28E4F4CD00ADAE3E /* Netable+Equatable.swift */, - C639674E28E4F58100ADAE3E /* String+FullyQualifiedURL.swift */, C639675028E5F89D00ADAE3E /* Netable+Error.swift */, + C639674E28E4F58100ADAE3E /* String+FullyQualifiedURL.swift */, ); path = Helper; sourceTree = ""; @@ -592,9 +598,11 @@ B8C9289F23E9FB1500DB2B37 /* URLRequest+EncodeParameters.swift in Sources */, C61DC6FC28CFDF3F0089E912 /* GraphQLRequest.swift in Sources */, B8C928A123E9FBA100DB2B37 /* Request.swift in Sources */, + C6DA3354293822230076F693 /* LossyArray.swift in Sources */, C65289F526D01829009D486B /* Config.swift in Sources */, C639674D28E4F4CD00ADAE3E /* Netable+Equatable.swift in Sources */, C6953F42241A95830044D278 /* LogDestination.swift in Sources */, + C64ADA47293F9ED900695444 /* ArrayDecodeStrategy.swift in Sources */, B8C9289D23E9FA0E00DB2B37 /* Error.swift in Sources */, C639674F28E4F58100ADAE3E /* String+FullyQualifiedURL.swift in Sources */, B8C928A323E9FBEC00DB2B37 /* HTTPMethod.swift in Sources */, @@ -663,6 +671,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.steamclock.NetableExample; PRODUCT_NAME = NetableExample; SUPPORTS_MACCATALYST = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -685,6 +694,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.steamclock.NetableExample; PRODUCT_NAME = NetableExample; SUPPORTS_MACCATALYST = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -837,6 +847,7 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -866,6 +877,7 @@ PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = YES; + SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/Netable/Netable/Helper/ArrayDecodeStrategy.swift b/Netable/Netable/Helper/ArrayDecodeStrategy.swift new file mode 100644 index 0000000..1d0cefb --- /dev/null +++ b/Netable/Netable/Helper/ArrayDecodeStrategy.swift @@ -0,0 +1,20 @@ +// +// ArrayDecodeStrategy.swift +// Netable +// +// Created by Brendan on 2022-12-06. +// Copyright © 2022 Steamclock Software. All rights reserved. +// + +import Foundation + +/// Strategy to use when decoding top-level arrays of values +/// By default, if you try to decode an array objects and one of the objects fails to decode, the entire array fails to decode. +/// Instead, this allows you to partially decode arrays and only return the well-formed elements. +/// For lossy decoding nested arrays, we recommend checking out [Better Codable](https://github.com/marksands/BetterCodable). +public enum ArrayDecodeStrategy { + /// If any element of the array fails to decode, the whole array fails. + case standard + /// Decode the array, omitting any elements that fail to decode. + case lossy +} diff --git a/Netable/Netable/LossyArray.swift b/Netable/Netable/LossyArray.swift new file mode 100644 index 0000000..43d20af --- /dev/null +++ b/Netable/Netable/LossyArray.swift @@ -0,0 +1,46 @@ +// +// LossyArray.swift +// Netable +// +// Created by Brendan on 2022-11-30. +// Copyright © 2022 Steamclock Software. All rights reserved. +// + +import Foundation + +/// Adapted from https://stackoverflow.com/a/46369152 + +/// Array container that allows for partial decoding of elements. +/// If an element of the array fails to decode, it will be omitted rather than the rest of the array failing to decode. +public struct LossyArray { + /// All elements of the array that decoded successfully. + public var elements: [Element] + + public init(elements: [Element]) { + self.elements = elements + } +} + +extension LossyArray: Decodable where Element: Decodable { + /// Decode non-optional item into an optional element. + public struct FailableDecodable: Decodable { + public var element: Element? + + /// Decode an element and set it to `nil` if decoding fails. + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + element = try? container.decode(Element.self) + } + } + + /// Attempt to decode the contents of an array, omitting any results that fail to decode. + public init(from decoder: Decoder) throws { + var elements = [Element?]() + var container = try decoder.unkeyedContainer() + while !container.isAtEnd { + let item = try container.decode(FailableDecodable.self).element + elements.append(item) + } + self.elements = elements.compactMap { $0 } + } +} diff --git a/Netable/Netable/Request.swift b/Netable/Netable/Request.swift index b32cc56..9e38d5a 100644 --- a/Netable/Netable/Request.swift +++ b/Netable/Netable/Request.swift @@ -24,6 +24,9 @@ public protocol Request: Sendable { /// See `FallbackDecoderViewController` for an example. associatedtype FallbackResource: Sendable = AnyObject + /// Allows for top-level arrays to be partially decoded if some elements fail to decode. + var arrayDecodeStrategy: ArrayDecodeStrategy { get } + /// Any headers that should be included with the request. /// Note that these will be set _after_ any global headers, /// and will thus take precedence if there's a duplicated key @@ -71,6 +74,10 @@ public extension Request { return Set() } + var arrayDecodeStrategy: ArrayDecodeStrategy { + .standard + } + func unredactedParameters(defaultEncodingStrategy: JSONEncoder.KeyEncodingStrategy) -> [String: String] { var output = [String: String]() @@ -114,6 +121,32 @@ public extension Request where FinalResource == RawResource { } } +public extension Request where + RawResource: Sequence, + RawResource: Decodable, + RawResource.Element: Decodable +{ + func decode(_ data: Data?, defaultDecodingStrategy: JSONDecoder.KeyDecodingStrategy) async throws -> RawResource { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.keyDecodingStrategy = jsonKeyDecodingStrategy ?? defaultDecodingStrategy + + guard let data = data else { throw NetableError.noData } + + do { + guard arrayDecodeStrategy == .lossy else { + return try decoder.decode(RawResource.self, from: data) + } + + let decoded = try decoder.decode(LossyArray.self, from: data) + + return decoded.elements as! Self.RawResource + } catch { + throw NetableError.decodingError(error, data) + } + } +} + public extension Request where RawResource: Decodable { func decode(_ data: Data?, defaultDecodingStrategy: JSONDecoder.KeyDecodingStrategy) async throws -> RawResource { let decoder = JSONDecoder() @@ -122,11 +155,9 @@ public extension Request where RawResource: Decodable { do { if RawResource.self == Empty.self { - let raw = try decoder.decode(RawResource.self, from: Empty.data) - return raw + return try decoder.decode(RawResource.self, from: Empty.data) } else if let data = data { - let raw = try decoder.decode(RawResource.self, from: data) - return raw + return try decoder.decode(RawResource.self, from: data) } } catch { throw NetableError.decodingError(error, data) @@ -150,6 +181,42 @@ extension CodingUserInfoKey { static let smartUnwrapKey = CodingUserInfoKey(rawValue: "smartUnwrapKey")! } +public extension Request where + RawResource == SmartUnwrap, + FinalResource: Sequence, + FinalResource: Decodable, + FinalResource.Element: Decodable +{ + func decode(_ data: Data?, defaultDecodingStrategy: JSONDecoder.KeyDecodingStrategy) async throws -> RawResource { + guard let data = data else { + throw NetableError.noData + } + + do { + let decoder = JSONDecoder() + decoder.userInfo = [ + .smartUnwrapKey: smartUnwrapKey + ] + + guard arrayDecodeStrategy == .lossy else { + return try decoder.decode(SmartUnwrap.self, from: data) + } + + let decodedType = try decoder.decode(SmartUnwrap>.self, from: data).decodedType + guard let finalResource = decodedType.elements as? FinalResource, + let rawResource = SmartUnwrap(decodedType: finalResource) as? SmartUnwrap else { + throw NetableError.resourceExtractionError("Failed to smart unwrap lossy decodable type. This is an internal error.") + } + + return rawResource + } catch { + throw NetableError.decodingError(error, data) + } + } +} + + + public extension Request where RawResource == SmartUnwrap { func decode(_ data: Data?, defaultDecodingStrategy: JSONDecoder.KeyDecodingStrategy) async throws -> RawResource { guard let data = data else { @@ -204,5 +271,3 @@ public extension Request where RawResource: Decodable, FallbackResource: Decodab public struct Empty: Codable { public static let data = "{}".data(using: .utf8)! } - - diff --git a/Netable/Netable/RetryConfiguration.swift b/Netable/Netable/RetryConfiguration.swift index 7ab90e4..94c1717 100644 --- a/Netable/Netable/RetryConfiguration.swift +++ b/Netable/Netable/RetryConfiguration.swift @@ -8,9 +8,9 @@ import Foundation -public struct RetryConfiguration { +public struct RetryConfiguration: Sendable { /// Specify types of networking errors to retry - public enum Errors { + public enum Errors: Sendable { /// No retries will happen case none @@ -21,7 +21,7 @@ public struct RetryConfiguration { case transport /// Test the errors with a user supplied closure. Custom errors are limited in the same way that ".all" is, there are certain types of errors (request formatting errors, cancellation, network timeouts) that this will NOT be called for and there is no option to retry. Note: will be called on a background thread so closure must be thread safe - case custom(retryTest: (NetableError) -> Bool) + case custom(retryTest: @Sendable (NetableError) -> Bool) internal func shouldRetry(_ error: NetableError) -> Bool { switch self { diff --git a/Netable/Netable/SmartUnwrap.swift b/Netable/Netable/SmartUnwrap.swift index a45e35d..00aedee 100644 --- a/Netable/Netable/SmartUnwrap.swift +++ b/Netable/Netable/SmartUnwrap.swift @@ -27,6 +27,10 @@ public struct SmartUnwrap: Decodable { } } + public init(decodedType: T) { + self.decodedType = decodedType + } + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: DynamicCodingKeys.self) let smartUnwrapKey = decoder.userInfo[.smartUnwrapKey] as? String diff --git a/Netable/NetableExample/Repository/PostRepository.swift b/Netable/NetableExample/Repository/PostRepository.swift index b5dfe7f..9669b15 100644 --- a/Netable/NetableExample/Repository/PostRepository.swift +++ b/Netable/NetableExample/Repository/PostRepository.swift @@ -46,7 +46,8 @@ class PostRepository { func getPosts() { Task { @MainActor in do { - let posts = try await netable.request(GetPostsRequest()) + let posts = try await self.netable.request(GetPostsRequest()) + print(posts) self.posts.send(posts) } catch { print(error) diff --git a/Netable/NetableExample/Request/GetPostsRequest.swift b/Netable/NetableExample/Request/GetPostsRequest.swift index bec2d52..e2eb977 100644 --- a/Netable/NetableExample/Request/GetPostsRequest.swift +++ b/Netable/NetableExample/Request/GetPostsRequest.swift @@ -17,6 +17,8 @@ struct GetPostsRequest: Request { var path = "all" + var arrayDecodeStrategy: ArrayDecodeStrategy { .lossy } + var unredactedParameterKeys: Set { ["title", "content"] } diff --git a/Netable/NetableExample/Resources/JsonResponse/posts.json b/Netable/NetableExample/Resources/JsonResponse/posts.json index 8c647fc..92102d7 100644 --- a/Netable/NetableExample/Resources/JsonResponse/posts.json +++ b/Netable/NetableExample/Resources/JsonResponse/posts.json @@ -1,8 +1,7 @@ { - "posts": [ + "posts":[ { - "title": "first post", - "content": "em ipsum dolor sit amet, consectetur adipiscing elit. Proin in mattis magna. Pellentesque ac tortor nec lectus auctor egestas. Proin erat felis, finibus vitae condimentum at, viverra sed arcu." + "title": "first post" }, { "title": "second post", diff --git a/Netable/NetableExample/Service/ExampleNetworkService.swift b/Netable/NetableExample/Service/ExampleNetworkService.swift index 466b6a1..2db0bd0 100644 --- a/Netable/NetableExample/Service/ExampleNetworkService.swift +++ b/Netable/NetableExample/Service/ExampleNetworkService.swift @@ -54,12 +54,19 @@ class ExampleNetworkService { private func loadJson(from path: String) -> HttpResponseBody { guard let path = Bundle.main.path(forResource: path, ofType: "json"), - let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe), - let jsonResult = try? JSONSerialization.jsonObject(with: data, options: .mutableLeaves), - let jsonResult = jsonResult as? Dictionary else { + let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe), + let jsonResult = try? JSONSerialization.jsonObject(with: data, options: .mutableLeaves) else { fatalError("Failed to load response JSON for: \(path)") } + if let result = jsonResult as? Dictionary { + return .json(result) + } + + if let result = jsonResult as? [Dictionary] { + return .json(result) + } + return .json(jsonResult) } } diff --git a/README.md b/README.md index 673aede..7f455fe 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,15 @@ Modern apps interact with a lot of different APIs. Netable makes that easier by - [Features](#features) - [Usage](#usage) + - [Standard Usage](#standard-usage) + - [Resource Extraction](#resource-extraction) + - [Handling Errors](#handling-errors) + - [GraphQL Support](#graphql-support) - [Example](#example) -- [Requirements](#requirements) + - [Full Documentation](#full-documentation) - [Installation](#installation) + - [Requirements](#requirements) + - [Supporting Earlier Versions][#supporting-earlier-versions-of-ios] - [License](#license) ## Features @@ -52,7 +58,7 @@ struct GetCatImages: Request { } ``` -### Make your request using `async`/`await` and handle the result: +#### Make your request using `async`/`await` and handle the result: ```swift Task { @@ -82,7 +88,7 @@ Task { } ``` -### Making a request with Combine +#### Making a request with Combine ```swift netable.request(GetCatImages()) @@ -113,7 +119,7 @@ netable.request(GetCatImages()) }.store(in: &cancellables) ``` -### Or, if you prefer good old fashioned callbacks +#### Or, if you prefer good old fashioned callbacks ```swift netable.request(GetCatImages()) { result in @@ -236,6 +242,14 @@ struct GetUserRequest: Request { ``` +#### Partially Decoding Arrays + +Sometimes, when decoding an array of objects, you may not want to fail the entire request if some of those objects fail to decode. + +To do this, you can set your Request's `arrayDecodeStrategy` to `.lossy` to return any elements that succeed to decode. + +Not that this will only work if your `RawResource: Sequence` or `RawResource: SmartUnwrap`. For better support of decoding nested, lossy, arrays we recommend checking out [Better Codable](https://github.com/marksands/BetterCodable) + ### Handling Errors In addition to handling errors locally that are thrown, or returned through `Result` objects, we provide two ways to handle errors globally. These can be useful for doing things like presenting errors in the UI for common error cases across multiple requests, or catching things like failed authentication requests to clear a stored user. @@ -276,7 +290,7 @@ Sometimes, you may want to specify a backup type to try and decode your response See [FallbackDecoderViewController](https://github.com/steamclock/netable/blob/master/Netable/NetableExample/Request/VersionCheckRequest.swift) in the Example project for a more detailed example. -#### GraphQL Support +### GraphQL Support While you can technically use `Netable` to manage GraphQL queries right out of the box, we've added a helper protocol to make your life a little bit easier, called `GraphQLRequest`. @@ -284,20 +298,20 @@ You can see a detailed example in the example project, but note that by default We recommend using a tool like [Postman](https://www.postman.com/) to document and test your queries. Also note that currently, shared fragments are not supported. +## Example + ### Full Documentation [In-depth documentation](https://steamclock.github.io/netable/) is provided through Jazzy and GitHub Pages. -## Example +## Installation -## Requirements +### Requirements - iOS 15.0+ - MacOS 10.15+ - Xcode 11.0+ -## Installation - Netable is available through **[Swift Package Manager](https://swift.org/package-manager/)**. To install it, follow these steps: 1. In Xcode, click **File**, then **Swift Package Manager**, then **Add Package Dependency**