-
-
Notifications
You must be signed in to change notification settings - Fork 159
/
GithubAPI.swift
226 lines (177 loc) · 8.84 KB
/
GithubAPI.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
import Siesta
import SwiftyJSON
// Depending on your taste, a Service can be a global var, a static var singleton, or a piece of more carefully
// controlled shared state passed between pieces of the app.
let GithubAPI = _GithubAPI()
class _GithubAPI {
// MARK: Configuration
private let service = Service(baseURL: "https://api.github.com")
fileprivate init() {
#if DEBUG
// Bare-bones logging of which network calls Siesta makes:
LogCategory.enabled = [.network]
// For more info about how Siesta decides whether to make a network call,
// and when it broadcasts state updates to the app:
//LogCategory.enabled = LogCategory.common
// For the gory details of what Siesta’s up to:
//LogCategory.enabled = LogCategory.detailed
#endif
// Global configuration
service.configure {
// By default, Siesta parses JSON using Foundation JSONSerialization.
// This transformer wraps that with SwiftyJSON.
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
// Custom transformers can change any response into any other — in this case, replacing the default error
// message with the one provided by the Github API.
$0.pipeline[.cleanup].add(GithubErrorMessageExtractor())
}
// Resource-specific configuration
service.configure("/search/**") {
$0.expirationTime = 10 // Refresh search results after 10 seconds (Siesta default is 30)
}
// Auth configuration
// Note the "**" pattern, which makes this config apply only to subpaths of baseURL.
// This prevents accidental credential leakage to untrusted servers.
service.configure("**") {
// This header configuration gets reapplied whenever the user logs in or out.
// How? See the basicAuthHeader property’s didSet.
$0.headers["Authorization"] = self.basicAuthHeader
}
// Mapping from specific paths to models
service.configureTransformer("/users/*") {
try User(json: $0.content) // Input type inferred because User.init takes JSON
}
service.configureTransformer("/users/*/repos") {
try ($0.content as JSON) // “as JSON” gives Siesta the expected input type
.arrayValue // SwiftyJSON defaults to []
.map(Repository.init) // Model mapping gives Siesta an implicit output type
}
service.configureTransformer("/search/repositories") {
try ($0.content as JSON)["items"]
.arrayValue
.map(Repository.init)
}
service.configureTransformer("/repos/*/*") {
try Repository(json: $0.content)
}
service.configure("/user/starred/*/*") { // Github gives 202 for “starred” and 404 for “not starred.”
$0.pipeline[.model].add( // This custom transformer turns that curious convention into
TrueIfResourceFoundTransformer()) // a resource whose content is a simple boolean.
}
// Note that you can use Siesta without these sorts of model mappings. By default, Siesta parses JSON, text,
// and images based on content type — and a resource will contain whatever the server happened to return, in a
// parsed but unstructured form (string, dictionary, etc.). If you prefer to work with raw dictionaries instead
// of models (good for rapid prototyping), then no additional transformer config is necessary.
//
// If you do apply a path-based mapping like the ones above, then any request for that path that does not return
// the expected type becomes an error. For example, "/users/foo" _must_ return a JSON response because that's
// what the User(json:) expects.
}
// MARK: Authentication
func logIn(username: String, password: String) {
if let auth = "\(username):\(password)".data(using: String.Encoding.utf8) {
basicAuthHeader = "Basic \(auth.base64EncodedString())"
}
}
func logOut() {
basicAuthHeader = nil
}
var isAuthenticated: Bool {
return basicAuthHeader != nil
}
private var basicAuthHeader: String? {
didSet {
// These two calls are almost always necessary when you have changing auth for your API:
service.invalidateConfiguration() // So that future requests for existing resources pick up config change
service.wipeResources() // Scrub all unauthenticated data
// Note that wipeResources() broadcasts a “no data” event to all observers of all resources.
// Therefore, if your UI diligently observes all the resources it displays, this call prevents sensitive
// data from lingering in the UI after logout.
}
}
// MARK: Endpoints
// You can turn your REST API into a nice Swift API using lightweight wrappers that return Siesta resources.
//
// Note that this class keeps its service private, making these methods the only entry points for the API.
// You could also choose to subclass Service, which makes methods like service.resource(…) available to
// your whole app. That approach is sometimes better for quick and dirty prototyping.
var activeRepositories: Resource {
return service
.resource("/search/repositories")
.withParam("q", "stars:>0")
.withParam("sort", "updated")
.withParam("order", "desc")
}
func user(_ username: String) -> Resource {
return service
.resource("/users")
.child(username.lowercased())
}
func repository(ownedBy login: String, named name: String) -> Resource {
return service
.resource("/repos")
.child(login)
.child(name)
}
func repository(_ repositoryModel: Repository) -> Resource {
return repository(
ownedBy: repositoryModel.owner.login,
named: repositoryModel.name)
}
func currentUserStarred(_ repositoryModel: Repository) -> Resource {
return service
.resource("/user/starred")
.child(repositoryModel.owner.login)
.child(repositoryModel.name)
}
func setStarred(_ isStarred: Bool, repository repositoryModel: Repository) -> Request {
let starredResource = currentUserStarred(repositoryModel)
return starredResource
.request(isStarred ? .put : .delete)
.onSuccess { _ in
// Update succeeded. Directly update the locally cached “starred / not starred” state.
starredResource.overrideLocalContent(with: isStarred)
// Ask server for an updated star count. Note that we only need to trigger the load here, not handle
// the response! Any UI that is displaying the star count will be observing this resource, and thus
// will pick up the change. The code that knows _when_ to trigger the load is decoupled from the code
// that knows _what_ to do with the updated data. This is the magic of Siesta.
self.repository(repositoryModel).load()
}
}
}
/// Wraps all response entity content with SwiftyJSON
private let SwiftyJSONTransformer =
ResponseContentTransformer(transformErrors: true)
{ JSON($0.content as AnyObject) }
/// If the response is JSON and has a "message" value, use it as the user-visible error message.
private struct GithubErrorMessageExtractor: ResponseTransformer {
func process(_ response: Response) -> Response {
switch response {
case .success:
return response
case .failure(var error):
let json = error.typedContent(ifNone: JSON.null)
error.userMessage = json["message"].string ?? error.userMessage
return .failure(error)
}
}
}
/// Special handling for detecting whether repo is starred; see "/user/starred/*/*" config above
private struct TrueIfResourceFoundTransformer: ResponseTransformer {
func process(_ response: Response) -> Response {
switch response {
case .success(var entity):
entity.content = true // Any success → true
return logTransformation(
.success(entity))
case .failure(let error):
if var entity = error.entity, error.httpStatusCode == 404 {
entity.content = false // 404 → false
return logTransformation(
.success(entity))
} else {
return response // Any other error remains unchanged
}
}
}
}