|
32 | 32 |
|
33 | 33 | # JSONSession |
34 | 34 |
|
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. |
36 | 36 |
|
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(...)` |
38 | 42 |
|
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>`. |
40 | 44 |
|
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 |
42 | 46 |
|
43 | | -## Parsing Responses |
| 47 | +The current design is intentionally one-shot and structured-concurrency friendly: |
44 | 48 |
|
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. |
46 | 54 |
|
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. |
48 | 56 |
|
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 |
50 | 58 |
|
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. |
52 | 63 |
|
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`. |
54 | 65 |
|
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 |
56 | 67 |
|
57 | 68 | ```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 |
69 | 74 | } |
70 | 75 |
|
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 |
82 | 78 | } |
83 | 79 |
|
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? |
86 | 83 |
|
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 | + } |
89 | 87 |
|
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 | +} |
94 | 92 |
|
| 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 | +} |
95 | 107 |
|
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 | +} |
97 | 122 |
|
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 | +``` |
99 | 139 |
|
100 | | -Strictly speaking the code works with Swift 5.2 on Apple platforms, though it requires a fairly modern SDK. |
| 140 | +## Requirements |
101 | 141 |
|
102 | | -### Made For Github |
| 142 | +See `Package.swift` for current platform and Swift toolchain requirements. |
103 | 143 |
|
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 |
105 | 145 |
|
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). |
0 commit comments