Skip to content

Commit 50cca82

Browse files
committed
Add session-actor design updates
1 parent 50d0764 commit 50cca82

11 files changed

Lines changed: 339 additions & 212 deletions

File tree

README.md

Lines changed: 87 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -32,75 +32,115 @@
3232

3333
# JSONSession
3434

35-
Support for periodic polling of a JSON REST resource.
35+
JSONSession is a small async client for JSON REST APIs that use bearer-token authentication.
3636

37-
Authentication is passed in the `Authorization` header, as `bearer <token>`.
37+
It provides:
38+
- a concurrency-safe `Session` actor for authenticated requests
39+
- a `Processor` / `ProcessorGroup` pipeline for typed response decoding
40+
- resource path abstraction via `ResourceResolver`
41+
- stream-based polling via `Session.pollData(...)`
3842

39-
The response is expected to contain an `Etag` header field, which represents the current state of the resource, and is passed back to the server with subsequent requests.
43+
Authentication is sent in the `Authorization` header as `bearer <token>`.
4044

41-
This mechanism allows efficient polling of the server for changes, and can be a workaround for rate-limiting (where requests that didn't pick up any change in state don't count towards the rate limit).
45+
## Design
4246

43-
## Parsing Responses
47+
The current design is intentionally one-shot and structured-concurrency friendly:
4448

45-
When a request is sent, it is passed a `ProcessorGroup` which contains a list of `Processor` objects.
49+
- `Session` is an actor, so request execution and transport use are isolated.
50+
- `Session` does not own long-lived background polling loops.
51+
- Callers own scheduling and cancellation policy (for example, an actor that sleeps and calls `session.request(...)`).
52+
- For continuous polling, callers can use `session.pollData(...)`, which returns an `AsyncStream` and cancels automatically when the stream terminates.
53+
- Processing remains pluggable: `ProcessorGroup` tries processors in order by HTTP status code and decode success.
4654

47-
When a response comes back, it is matched against each `Processor` in turn, matching against the HTTP status code. If a processor supports the code, it is given a chance to decode the response.
55+
This keeps JSONSession focused on transport + decoding, while application/domain layers own timing decisions.
4856

49-
If a processor fails to decode the response (throws an error), matching is continued unless the list of processors is exhausted. The first successful match ends this process. If all processors are exhausted without success, then the `unprocessed` method of the `ProcessorGroup` is called; this can be used for catch-all error handling.
57+
## Intended Usage
5058

51-
## Example
59+
1. Define a `ResourceResolver` for each endpoint.
60+
2. Define one or more `Processor` types for expected status/payload pairs.
61+
3. Compose processors into a `ProcessorGroup`.
62+
4. Call `await session.request(...)` for each fetch cycle your app decides to run.
5263

53-
This simple example polls the endpoint `https://some.endpoint/v1/` for the resource `some/rest/resource`.
64+
You can also call `await session.data(for:)` when you want raw bytes and the `HTTPURLResponse`.
5465

55-
When something has changed, a JSON response will be sent back. If the HTTP status code is one that we expect, we will decode the JSON into a Swift object, and call one of our Processor objects with it.
66+
## Example
5667

5768
```swift
58-
/// if the response is 200, the server will send us an item
59-
struct ItemProcessor: Processor {
60-
struct Item: Decodable {
61-
let name: String
62-
}
63-
64-
let codes = [200]
65-
func process(_ item: Item, response: HTTPURLResponse, in session: Session) -> RepeatStatus {
66-
print("Received item \(item.name)")
67-
return .inherited
68-
}
69+
import Foundation
70+
import JSONSession
71+
72+
struct Item: Decodable {
73+
let name: String
6974
}
7075

71-
/// if the response is 400, the server will send us an error
72-
struct ErrorProcessor: Processor {
73-
struct Error: Decodable {
74-
let error: String
75-
}
76-
77-
let codes = [400]
78-
func process(_ payload: Error, response: HTTPURLResponse, in session: Session) -> RepeatStatus {
79-
print("Something went wrong: \(payload.error)")
80-
return .inherited
81-
}
76+
struct ErrorPayload: Decodable {
77+
let error: String
8278
}
8379

84-
// make a session for the service we're targetting, supplying the authorization token
85-
let session = Session(base: URL(string: "https://some.endpoint/v1/")!, token: "<api-token>")
80+
actor Context {
81+
var lastItem: Item?
82+
var lastError: String?
8683

87-
// schedule polling of some REST resource
88-
session.poll(target: Resource("some/rest/request"), processors: [ItemProcessor(), ErrorProcessor()], repeatingEvery: 1.0)
84+
func setItem(_ item: Item) {
85+
lastItem = item
86+
}
8987

90-
// the endpoint will be queried repeatedly by the session
91-
// when an expected response comes back, the response will be decoded and one of our processor objects will be called to process it
92-
RunLoop.main.run()
93-
```
88+
func setError(_ message: String) {
89+
lastError = message
90+
}
91+
}
9492

93+
struct ItemProcessor: Processor {
94+
typealias Context = Context
95+
let codes = [200]
96+
97+
func process(
98+
_ payload: Item,
99+
response _: HTTPURLResponse,
100+
for _: Request<Context>,
101+
in context: Context
102+
) async throws -> RepeatStatus {
103+
await context.setItem(payload)
104+
return .cancel
105+
}
106+
}
95107

96-
### Requirements
108+
struct ErrorProcessor: Processor {
109+
typealias Context = Context
110+
let codes = [400]
111+
112+
func process(
113+
_ payload: ErrorPayload,
114+
response _: HTTPURLResponse,
115+
for _: Request<Context>,
116+
in context: Context
117+
) async throws -> RepeatStatus {
118+
await context.setError(payload.error)
119+
return .cancel
120+
}
121+
}
97122

98-
The `swift-tools-version` requirement is set to Swift 5, as the Foundation Networking API isn't quite right on Linux prior to 5.3.
123+
let session = Session(base: URL(string: "https://some.endpoint/v1/")!, token: "<api-token>")
124+
let context = Context()
125+
let processors = AnyProcessorGroup(
126+
name: "Item Query",
127+
processors: [
128+
ItemProcessor().eraseToAnyProcessor(),
129+
ErrorProcessor().eraseToAnyProcessor(),
130+
]
131+
)
132+
133+
await session.request(
134+
target: Resource("some/rest/resource"),
135+
context: context,
136+
processors: processors
137+
)
138+
```
99139

100-
Strictly speaking the code works with Swift 5.2 on Apple platforms, though it requires a fairly modern SDK.
140+
## Requirements
101141

102-
### Made For Github
142+
See `Package.swift` for current platform and Swift toolchain requirements.
103143

104-
This is a generalisation of some code I built to access the Github API, and is used by [Octoid](https://github.com/elegantchaos/Octoid) which is a more general Github library.
144+
## Made For GitHub
105145

106-
I split out the JSONSession functionality because I imagined that other servers may use the same mechanism. This may be an incorrect assumption, and/or this code may need to be generalised further to work with other servers. If so, let me know via an issue.
146+
JSONSession originated as part of GitHub API integrations and is used by [Octoid](https://github.com/elegantchaos/Octoid).

Sources/JSONSession/Failure.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55

66
import Foundation
77

8+
/// Standard GitHub-style API failure payload.
89
public struct Failure: Codable, Sendable {
10+
/// Human-readable failure summary.
911
let message: String
12+
/// Documentation URL supplied by the API.
1013
let documentation_url: String
1114

15+
/// Indicates whether this failure can be ignored by higher-level callers.
1216
var canIgnore: Bool { false }
1317
}

Sources/JSONSession/Processor.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ extension AnyProcessor: ProcessorGroup {
6565

6666
/// Typed processor convenience protocol for concrete payload/context types.
6767
public protocol Processor<Context>: Sendable {
68-
/// Context type supplied by the caller while polling.
68+
/// Context type supplied by the caller while processing a request.
6969
associatedtype Context: Sendable
7070
/// Decoded response type this processor consumes.
7171
associatedtype Payload: Decodable

Sources/JSONSession/ProcessorGroup.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public protocol ProcessorGroup<Context>: Sendable {
2424
var groupIsProcessor: Bool { get }
2525

2626
/// Path to the resource we process.
27-
func path(for target: ResourceResolver, in session: Session) -> String
27+
func path(for target: any ResourceResolver) -> String
2828

2929
/// Decode a response and return repeat behavior.
3030
func decode(
@@ -36,11 +36,14 @@ public protocol ProcessorGroup<Context>: Sendable {
3636
}
3737

3838
extension ProcessorGroup {
39+
/// Default name used when a group does not provide one.
3940
public var name: String { "untitled group" }
41+
/// Default assumes this value is a group and not a single processor.
4042
public var groupIsProcessor: Bool { false }
4143

42-
public func path(for target: ResourceResolver, in session: Session) -> String {
43-
target.path(in: session)
44+
/// Resolves request path from the provided target.
45+
public func path(for target: any ResourceResolver) -> String {
46+
target.path
4447
}
4548

4649
public func decode(
@@ -80,6 +83,7 @@ extension ProcessorGroup {
8083
throw Session.Errors.unexpectedResponse(response.statusCode)
8184
}
8285

86+
/// Returns a consistent log message for successful processing.
8387
private func processedMessage(processor: AnyProcessor<Context>, status: RepeatStatus) -> String {
8488
let nameInfo = groupIsProcessor ? name : "\(name) using \(processor.name)"
8589
return "Processed \(nameInfo). Repeat status: \(status)."
@@ -88,9 +92,12 @@ extension ProcessorGroup {
8892

8993
/// Convenience group wrapper for a list of erased processors.
9094
public struct AnyProcessorGroup<Context: Sendable>: ProcessorGroup {
95+
/// Group name used in logs.
9196
public let name: String
97+
/// Ordered processor chain used for decoding and handling responses.
9298
public let processors: [AnyProcessor<Context>]
9399

100+
/// Creates a named group from an ordered list of processors.
94101
public init(name: String, processors: [AnyProcessor<Context>]) {
95102
self.name = name
96103
self.processors = processors

Sources/JSONSession/Query.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ import Foundation
99
import FoundationNetworking
1010
#endif
1111

12+
/// Legacy helper for constructing authenticated URL requests.
1213
public struct Query {
14+
/// Human-readable query name.
1315
let name: String
14-
let query: (ResourceResolver, Session) -> String
16+
/// Closure that resolves query-specific path components.
17+
let query: @Sendable (any ResourceResolver, Session) -> String
1518

16-
func request(for target: ResourceResolver, in session: Session) -> URLRequest {
19+
/// Builds an authenticated GET request for a target resource.
20+
func request(for target: any ResourceResolver, in session: Session) -> URLRequest {
1721
let authorization = "bearer \(session.token)"
18-
var request = URLRequest(url: session.base.appendingPathComponent(target.path(in: session)))
22+
var request = URLRequest(url: session.base.appendingPathComponent(target.path))
1923
request.addValue(authorization, forHTTPHeaderField: "Authorization")
2024
request.httpMethod = "GET"
2125
return request

Sources/JSONSession/Request.swift

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,25 @@ import Foundation
99
import FoundationNetworking
1010
#endif
1111

12-
/// Runtime state for an individual polling request.
13-
public struct Request<Context: Sendable>: @unchecked Sendable {
12+
/// Runtime state for a single request execution.
13+
public struct Request<Context: Sendable>: Sendable {
1414
/// Resource being polled.
15-
public let resource: ResourceResolver
15+
public let resource: any ResourceResolver
1616
/// Processor chain used to decode/handle responses.
1717
let processors: any ProcessorGroup<Context>
1818
/// Optional ETag for conditional requests.
1919
var tag: String?
20-
/// Indicates whether polling should continue after a response.
20+
/// Indicates whether follow-up polling was requested by processing.
2121
var repeating: Bool
22-
/// Current repeat interval in seconds.
22+
/// Repeat interval hint from response headers.
2323
var interval: TimeInterval
2424

25+
/// Next scheduled repeat time when `repeating` is true.
2526
var repeatTime: DispatchTime? {
2627
repeating ? DispatchTime.now().advanced(by: interval.asDispatchTimeInterval) : nil
2728
}
2829

30+
/// Updates repeat behavior using processor output.
2931
mutating func updateRepeat(status: RepeatStatus) {
3032
switch status {
3133
case .request: repeating = true
@@ -34,6 +36,7 @@ public struct Request<Context: Sendable>: @unchecked Sendable {
3436
}
3537
}
3638

39+
/// Caps the repeat interval with `X-Poll-Interval` when present.
3740
mutating func capInterval(to seconds: Double) {
3841
let current = interval
3942
let interval = max(current, seconds)
@@ -42,9 +45,10 @@ public struct Request<Context: Sendable>: @unchecked Sendable {
4245
}
4346
}
4447

48+
/// Builds an authenticated URL request for this target.
4549
func urlRequest(for session: Session) -> URLRequest {
4650
let authorization = "bearer \(session.token)"
47-
let path = processors.path(for: resource, in: session)
51+
let path = processors.path(for: resource)
4852
var request = URLRequest(url: session.base.appendingPathComponent(path))
4953
request.addValue(authorization, forHTTPHeaderField: "Authorization")
5054
request.httpMethod = "GET"
@@ -58,20 +62,23 @@ public struct Request<Context: Sendable>: @unchecked Sendable {
5862
return request
5963
}
6064

65+
/// Logs scheduled request timing details.
6166
func log(deadline: DispatchTime) {
6267
let distance = DispatchTime.now().distance(to: deadline).asTimeInterval
6368
let timeInfo = distance < 0 ? "now." : "in \(seconds: distance)."
6469
let repeatInfo = repeating ? " Will repeat in \(seconds: interval)." : ""
6570
sessionChannel.log("Polling for \(processors.name) \(timeInfo)\(repeatInfo)")
6671
}
6772

73+
/// Logs decoder or processor errors with payload context.
6874
func log(error: Error, data: Data) {
6975
sessionChannel.log(
7076
"Error thrown:\n- query: \(processors.name)\n- target: \(resource)\n- processor: \(processors.name)\n- error: \(error)\n"
7177
)
7278
sessionChannel.log("- data: \(data.prettyPrinted)\n\n")
7379
}
7480

81+
/// Logs successful transport receipt.
7582
func log(response: URLResponse?) {
7683
if let _ = response {
7784
networkingChannel.log("got response for \(resource)")

Sources/JSONSession/Resource.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
import Foundation
77

88
/// Basic resource with a fixed path.
9-
109
public struct Resource: ResourceResolver {
11-
let path: String
12-
public func path(in _: Session) -> String { path }
10+
/// Relative path appended to the configured API base URL.
11+
public let path: String
12+
/// Creates a fixed-path resource.
1313
public init(_ path: String) { self.path = path }
1414
}

Sources/JSONSession/ResourceResolver.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import Foundation
77

88
/// Something that resolves to a path to a REST resource.
9-
public protocol ResourceResolver {
10-
func path(in session: Session) -> String
9+
public protocol ResourceResolver: Sendable {
10+
/// Relative path for this resource.
11+
var path: String { get }
1112
}

0 commit comments

Comments
 (0)