Skip to content

Commit

Permalink
Request/Response (#3)
Browse files Browse the repository at this point in the history
* Initial Request/Response Implementation

* Client API

* Stub EmulatedClient

* Public modifier for SelfSignedSessionDelegate

* Deprecated HTTP.x typealiases

* Updated Readme; Added Convenience Requests; Split Emulation

* Isolated URLSessionDelegate to ObjectiveC
  • Loading branch information
richardpiazza authored Mar 27, 2022
1 parent bae4735 commit b525624
Show file tree
Hide file tree
Showing 37 changed files with 1,171 additions and 260 deletions.
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Contributing
29 changes: 22 additions & 7 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@ import PackageDescription
let package = Package(
name: "SessionPlus",
platforms: [
.macOS(.v10_14),
.iOS(.v12),
.tvOS(.v12),
.watchOS(.v5),
.macOS(.v10_15),
.iOS(.v13),
.tvOS(.v13),
.watchOS(.v6),
],
products: [
// Products define the executables and libraries produced by a package, and make them visible to other packages.
.library(
name: "SessionPlus",
targets: ["SessionPlus"]),
targets: [
"SessionPlus",
"SessionPlusEmulation"
]
),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
Expand All @@ -26,10 +30,21 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages which this package depends on.
.target(
name: "SessionPlus",
dependencies: []),
dependencies: []
),
.target(
name: "SessionPlusEmulation",
dependencies: [
"SessionPlus"
]
),
.testTarget(
name: "SessionPlusTests",
dependencies: ["SessionPlus"]),
dependencies: [
"SessionPlus",
"SessionPlusEmulation"
]
),
],
swiftLanguageVersions: [.v5]
)
99 changes: 41 additions & 58 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# SessionPlus

A collection of extensions & wrappers around URLSession.
A swift _request & response_ framework for JSON apis.

<p>
<img src="https://github.com/richardpiazza/SessionPlus/workflows/Swift/badge.svg?branch=main" />
Expand All @@ -15,84 +15,67 @@ This package has been designed to work across multiple swift environments by uti
## Installation

**SessionPlus** is distributed using the [Swift Package Manager](https://swift.org/package-manager).
To install it into a project, add it as a dependency within your `Package.swift` manifest:
You can add it using Xcode or by listing it as a dependency in your `Package.swift` manifest:

```swift
let package = Package(
...
dependencies: [
.package(url: "https://github.com/richardpiazza/SessionPlus.git", .upToNextMinor(from: "1.2.0")
],
...
...
dependencies: [
.package(url: "https://github.com/richardpiazza/SessionPlus.git", .upToNextMajor(from: "2.0.0")
],
...
targets: [
.target(
name: "MyPackage",
dependnecies: [
"SessionPlus"
]
)
]
)
```

Then import the **SessionPlus** packages wherever you'd like to use it:
## Usage

```swift
import SessionPlus
```

## Quick Start

Checkout the `WebAPI` class.
**SessionPlus** offers a default implementation (`URLSessionClient`) that allows for requesting data from a JSON api. For example:

```swift
open class WebAPI: HTTPClient, HTTPCodable, HTTPInjectable {

public var baseURL: URL
public var session: URLSession
public var authorization: HTTP.Authorization?
public var jsonEncoder: JSONEncoder = JSONEncoder()
public var jsonDecoder: JSONDecoder = JSONDecoder()
public var injectedResponses: [InjectedPath : InjectedResponse] = [:]
public init(baseURL: URL, session: URLSession? = nil, delegate: URLSessionDelegate? = nil) {
}
}
let url = URL(string: "https://api.agify.io")!
let client = URLSessionClient(baseURL: url)
let request = Get(queryItems: [URLQueryItem(name: "name", value: "bob")])
let response = try await client.request(request)
```

`WebAPI` provides a basic implementation for an _out-of-the-box_ HTTP/REST/JSON client.

## Components
### Decoding

### HTTPClient
The `Client` protocol also offers extensions for automatically decoding responses to any `Decodable` type.

```swift
public protocol HTTPClient {
var baseURL: URL { get }
var session: URLSession { get set }
var authorization: HTTP.Authorization? { get set }
func request(method: HTTP.RequestMethod, path: String, queryItems: [URLQueryItem]?, data: Data?) throws -> URLRequest
func task(request: URLRequest, completion: @escaping HTTP.DataTaskCompletion) throws -> URLSessionDataTask
func execute(request: URLRequest, completion: @escaping HTTP.DataTaskCompletion)
struct ApiResult: Decodable {
let name: String
let age: Int
let count: Int
}
```

`URLSession` is task-driven. The **SessionPlus** api is designed with this in mind; allowing you to construct your request and then either creating a _data task_ for you to references and execute, or automatically executing the request.

Example conformances for `request(method:path:queryItems:data:)`, `task(request:, completion)`, & `execute(request:completion:)` are provided in an extension, so the minimum required conformance to `HTTPClient` is `baseURL`, `session`, and `authorization`.

Convenience methods for the common HTTP request methods **get**, **put**, **post**, **delete**, and **patch**, are all provided.

### HTTPCodable

```swift
public protocol HTTPCodable {
var jsonEncoder: JSONEncoder { get set }
var jsonDecoder: JSONDecoder { get set }
}
let response = try await client.request(request) as ApiResult
...
let response: ApiResult = try await client.request(request)
```

The `HTTPCodable` protocol is used to extend an `HTTPClient` implementation with support for encoding and decoding of JSON bodies.
### Flexibility

### HTTPInjectable
The `Client` protocol declares up to three forms requests based on platform abilities:

```swift
public protocol HTTPInjectable {
var injectedResponses: [InjectedPath : InjectedResponse] { get set }
}
// async/await for swift 5.5+
func performRequest(_ request: Request) async throws -> Response
// completion handler for backwards compatibility
func performRequest(_ request: Request, completion: @escaping (Result<Response, Error>) -> Void)
// Combine publisher that emits with a response
func performRequest(_ request: Request) -> AnyPublisher<Response, Error>
```

The `HTTPInjectable` protocol is used to extend an `HTTPClient` implementation by overriding the default `execute(request:completion:)` implementation to allow for the definition and usage of predefined responses. This makes for simple testing!
## Contribution

Contributions to **SessionPlus** are welcomed and encouraged! See the [Contribution Guide](CONTRIBUTING.md) for more information.
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ import FoundationNetworking
import UIKit
#endif

@available(*, deprecated, renamed: "Downloader.DataCompletion")
public typealias DownloaderDataCompletion = Downloader.DataCompletion

/// A wrapper for `URLSession` similar to `WebAPI` for general purpose downloading of data and images.
@available(*, deprecated, message: "This will be removed in future versions of SessionPlus.")
open class Downloader {

public typealias DataCompletion = (_ statusCode: Int, _ responseData: Data?, _ error: Error?) -> Void
Expand Down Expand Up @@ -147,10 +145,8 @@ open class Downloader {
}

#if canImport(UIKit)
@available(*, deprecated, renamed: "Downloader.ImageCompletion")
public typealias DownloaderImageCompletion = Downloader.ImageCompletion

/// A wrapper for `URLSession` similar to `WebAPI` for general purpose downloading of data and images.
@available(*, deprecated, message: "This will be removed in future versions of SessionPlus.")
public extension Downloader {
func getImageAtURL(_ url: URL, cachePolicy: URLRequest.CachePolicy, completion: @escaping ImageCompletion) {
self.getDataAtURL(url, cachePolicy: cachePolicy) { (statusCode, responseData, error) -> Void in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@ import FoundationNetworking
#endif

/// A Collection of methods/headers/values/types used during basic HTTP interactions.
@available(*, deprecated)
public struct HTTP {

/// HTTP Headers as provided from HTTPURLResponse
public typealias Headers = [AnyHashable : Any]

/// General errors that may be encountered during HTTP request/response handling.
@available(*, deprecated)
public enum Error: Swift.Error, LocalizedError {
case invalidURL
case invalidRequest
case invalidResponse
case undefined(Swift.Error?)

public var errorDescription: String? {
switch self {
Expand All @@ -23,35 +22,36 @@ public struct HTTP {
return "Invalid URL Request: URLRequest is nil or invalid."
case .invalidResponse:
return "Invalid URL Response: HTTPURLResponse is nil or invalid."
case .undefined(let error):
return "Undefined Error: \(error?.localizedDescription ?? "")"
}
}
}

/// A general completion handler for HTTP requests.
@available(*, deprecated)
public typealias DataTaskCompletion = (_ statusCode: Int, _ headers: Headers?, _ data: Data?, _ error: Swift.Error?) -> Void

#if swift(>=5.5) && canImport(ObjectiveC)
/// The output of an async url request execution.
@available(*, deprecated)
public typealias AsyncDataTaskOutput = (statusCode: Int, headers: Headers, data: Data)
#endif
}

public extension URLRequest {
/// Sets a value for the header field.
///
/// - parameters:
/// - value: The new value for the header field. Any existing value for the field is replaced by the new value.
/// - header: The header for which to set the value. (Headers are case sensitive)
mutating func setValue(_ value: String, forHTTPHeader header: HTTP.Header) {
self.setValue(value, forHTTPHeaderField: header.rawValue)
}

/// Sets a value for the header field.
///
/// - parameters:
/// - value: The new value for the header field. Any existing value for the field is replaced by the new value.
/// - header: The header for which to set the value. (Headers are case sensitive)
mutating func setValue(_ value: HTTP.MIMEType, forHTTPHeader header: HTTP.Header) {
self.setValue(value.rawValue, forHTTPHeaderField: header.rawValue)
}
// The HTTP.* name-spacing will be removed in future versions of SessionPlus.
@available(*, deprecated)
public extension HTTP {
@available(*, deprecated, renamed: "Authorization")
typealias Authorization = SessionPlus.Authorization
@available(*, deprecated, renamed: "Header")
typealias Header = SessionPlus.Header
@available(*, deprecated, renamed: "MIMEType")
typealias MIMEType = SessionPlus.MIMEType
@available(*, deprecated, renamed: "Method")
typealias RequestMethod = Method
@available(*, deprecated, renamed: "StatusCode")
typealias StatusCode = SessionPlus.StatusCode
@available(*, deprecated, renamed: "Headers")
typealias Headers = SessionPlus.Headers
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import FoundationNetworking
/// The essential components of a HTTP/REST/JSON Client.
///
/// This protocol expresses a lightweight wrapper around Foundations `URLSession` for interacting with JSON REST API's.
@available(*, deprecated, message: "See 'Client' for more information.")
public protocol HTTPClient {

/// The root URL used to construct all queries.
Expand Down Expand Up @@ -42,6 +43,7 @@ public protocol HTTPClient {
#endif
}

@available(*, deprecated, message: "See 'Client' for more information.")
public extension HTTPClient {
func request(method: HTTP.RequestMethod, path: String, queryItems: [URLQueryItem]?, data: Data?) throws -> URLRequest {
let pathURL = baseURL.appendingPathComponent(path)
Expand All @@ -57,14 +59,14 @@ public extension HTTPClient {
request.httpMethod = method.rawValue
if let data = data {
request.httpBody = data
request.setValue("\(data.count)", forHTTPHeader: HTTP.Header.contentLength)
request.setValue("\(data.count)", forHeader: .contentLength)
}
request.setValue(HTTP.Header.dateFormatter.string(from: Date()), forHTTPHeader: HTTP.Header.date)
request.setValue(HTTP.MIMEType.json, forHTTPHeader: HTTP.Header.accept)
request.setValue(HTTP.MIMEType.json, forHTTPHeader: HTTP.Header.contentType)
request.setValue(Header.dateFormatter.string(from: Date()), forHeader: .date)
request.setValue(.json, forHeader: .accept)
request.setValue(.json, forHeader: .contentType)

if let authorization = self.authorization {
request.setValue(authorization.headerValue, forHTTPHeader: HTTP.Header.authorization)
request.setValue(authorization.headerValue, forHeader: .authorization)
}

return request
Expand Down Expand Up @@ -150,6 +152,7 @@ public extension HTTPClient {

#if swift(>=5.5) && canImport(ObjectiveC)
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
@available(*, deprecated, message: "See 'Client' for more information.")
public extension HTTPClient {
func execute(request: URLRequest) async throws -> HTTP.AsyncDataTaskOutput {
let sessionData = try await session.data(for: request)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Foundation
import FoundationNetworking
#endif

@available(*, deprecated, message: "See 'Client' for more information.")
public extension HTTP {
typealias CodableTaskCompletion<D: Decodable> = (_ statusCode: Int, _ headers: Headers?, _ data: D?, _ error: Swift.Error?) -> Void
#if swift(>=5.5) && canImport(ObjectiveC)
Expand All @@ -12,11 +13,13 @@ public extension HTTP {

/// Protocol used to extend an `HTTPClient` with support for automatic encoding and decoding or request and response
/// data.
@available(*, deprecated, message: "See 'Client' for more information.")
public protocol HTTPCodable {
var jsonEncoder: JSONEncoder { get set }
var jsonDecoder: JSONDecoder { get set }
}

@available(*, deprecated, message: "See 'Client' for more information.")
public extension HTTPCodable where Self: HTTPClient {
func encode<E: Encodable>(_ encodable: E?) throws -> Data? {
var data: Data? = nil
Expand Down Expand Up @@ -104,6 +107,7 @@ public extension HTTPCodable where Self: HTTPClient {

#if swift(>=5.5) && canImport(ObjectiveC)
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
@available(*, deprecated, message: "See 'Client' for more information.")
public extension HTTPCodable where Self: HTTPClient {
/// Uses the `jsonDecoder` to deserialize a `Decodable` type from the provided response.
///
Expand Down
Loading

0 comments on commit b525624

Please sign in to comment.