Skip to content

Commit

Permalink
Adding HTTP response validation
Browse files Browse the repository at this point in the history
  • Loading branch information
mattt committed Sep 24, 2014
1 parent 1e634b3 commit 3040ba6
Show file tree
Hide file tree
Showing 5 changed files with 512 additions and 39 deletions.
4 changes: 4 additions & 0 deletions Alamofire.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
F8858DDD19A96B4300F55F93 /* RequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8111E5D19A9674D0040E7D1 /* RequestTests.swift */; };
F8858DDE19A96B4400F55F93 /* ResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8111E5E19A9674D0040E7D1 /* ResponseTests.swift */; };
F897FF4119AA800700AB5182 /* Alamofire.swift in Sources */ = {isa = PBXBuildFile; fileRef = F897FF4019AA800700AB5182 /* Alamofire.swift */; };
F8AE910219D28DCC0078C7B2 /* ValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8AE910119D28DCC0078C7B2 /* ValidationTests.swift */; };
F8E6024519CB46A800A3E7F1 /* AuthenticationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E6024419CB46A800A3E7F1 /* AuthenticationTests.swift */; };
/* End PBXBuildFile section */

Expand All @@ -39,6 +40,7 @@
F8111E5E19A9674D0040E7D1 /* ResponseTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ResponseTests.swift; sourceTree = "<group>"; };
F8111E5F19A9674D0040E7D1 /* UploadTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UploadTests.swift; sourceTree = "<group>"; };
F897FF4019AA800700AB5182 /* Alamofire.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Alamofire.swift; sourceTree = "<group>"; };
F8AE910119D28DCC0078C7B2 /* ValidationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ValidationTests.swift; sourceTree = "<group>"; };
F8E6024419CB46A800A3E7F1 /* AuthenticationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -105,6 +107,7 @@
F8111E5F19A9674D0040E7D1 /* UploadTests.swift */,
F8111E5B19A9674D0040E7D1 /* DownloadTests.swift */,
F8E6024419CB46A800A3E7F1 /* AuthenticationTests.swift */,
F8AE910119D28DCC0078C7B2 /* ValidationTests.swift */,
F8111E4019A95C8B0040E7D1 /* Supporting Files */,
);
path = Tests;
Expand Down Expand Up @@ -236,6 +239,7 @@
F8858DDD19A96B4300F55F93 /* RequestTests.swift in Sources */,
F8E6024519CB46A800A3E7F1 /* AuthenticationTests.swift in Sources */,
F8858DDE19A96B4400F55F93 /* ResponseTests.swift in Sources */,
F8AE910219D28DCC0078C7B2 /* ValidationTests.swift in Sources */,
F8111E6119A9674D0040E7D1 /* ParameterEncodingTests.swift in Sources */,
F8111E6419A9674D0040E7D1 /* UploadTests.swift in Sources */,
F8111E6019A9674D0040E7D1 /* DownloadTests.swift in Sources */,
Expand Down
33 changes: 29 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,17 @@ Of course, AFNetworking remains the premiere networking library available for Ma
- Upload File / Data / Stream
- Download using Request or Resume data
- Authentication with NSURLCredential
- HTTP Response Validation
- Progress Closure & NSProgress
- cURL Debug Output
- Comprehensive Unit Test Coverage
- Complete Documentation

### Planned for 1.0 Release*

_* Coming very soon_

- Comprehensive Unit Test Coverage
- Complete Documentation
- HTTP Response Validation
- TLS Chain Validation
- [ ] TLS Chain Validation

## Requirements

Expand Down Expand Up @@ -288,6 +288,31 @@ Alamofire.request(.GET, "https://httpbin.org/basic-auth/\(user)/\(password)")
}
```

### Validation

#### Manual

```swift
Alamofire.request(.GET, "http://httpbin.org/get", parameters: ["foo": "bar"])
.validate(statusCode: 200..<300)
.validate(contentType: ["application/json"])
.response { (_, _, _, error) in
println(error)
}
```

#### Automatic

Automatically validates status code within `200...299` range, and that the `Content-Type` header of the response matches the `Accept` header of the request, if one is provided.

```swift
Alamofire.request(.GET, "http://httpbin.org/get", parameters: ["foo": "bar"])
.validate()
.response { (_, _, _, error) in
println(error)
}
```

### Printable

```swift
Expand Down
164 changes: 151 additions & 13 deletions Source/Alamofire.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@

import Foundation

/// Alamofire errors
public let AlamofireErrorDomain = "com.alamofire.error"

/**
HTTP method definitions.

Expand Down Expand Up @@ -619,19 +622,13 @@ public class Request {
*/
public func response(priority: Int = DISPATCH_QUEUE_PRIORITY_DEFAULT, queue: dispatch_queue_t? = nil, serializer: Serializer, completionHandler: (NSURLRequest, NSHTTPURLResponse?, AnyObject?, NSError?) -> Void) -> Self {

dispatch_async(delegate.queue, {
dispatch_async(dispatch_get_global_queue(priority, 0), {
if var error = self.delegate.error {
dispatch_async(queue ?? dispatch_get_main_queue(), {
completionHandler(self.request, self.response, nil, error)
})
} else {
let (responseObject: AnyObject?, serializationError: NSError?) = serializer(self.request, self.response, self.delegate.data)
dispatch_sync(delegate.queue, {
dispatch_sync(dispatch_get_global_queue(priority, 0), {
let (responseObject: AnyObject?, serializationError: NSError?) = serializer(self.request, self.response, self.delegate.data)

dispatch_async(queue ?? dispatch_get_main_queue(), {
completionHandler(self.request, self.response, responseObject, serializationError)
})
}
dispatch_async(queue ?? dispatch_get_main_queue(), {
completionHandler(self.request, self.response, responseObject, self.delegate.error ?? serializationError)
})
})
})

Expand Down Expand Up @@ -739,7 +736,10 @@ public class Request {
}

func URLSession(session: NSURLSession!, task: NSURLSessionTask!, didCompleteWithError error: NSError!) {
self.error = error
if error != nil {
self.error = error
}

dispatch_resume(queue)
}
}
Expand Down Expand Up @@ -805,6 +805,144 @@ public class Request {
}
}

// MARK: - Validation

extension Request {

/**
A closure used to validate a request that takes a URL request and URL response, and returns whether the request was valid.
*/
public typealias Validation = (NSURLRequest, NSHTTPURLResponse) -> (Bool)

/**
Validates the request, using the specified closure.

If validation fails, subsequent calls to response handlers will have an associated error.

:param: validation A closure to validate the request.

:returns: The request.
*/
public func validate(validation: Validation) -> Self {
return response(priority: DISPATCH_QUEUE_PRIORITY_HIGH, queue: self.delegate.queue, serializer: Request.responseDataSerializer()){ (request, response, data, error) in
if response != nil && error == nil {
if !validation(request, response!) {
self.delegate.error = NSError(domain: AlamofireErrorDomain, code: -1, userInfo: nil)
}
}
}
}

// MARK: Status Code

private class func response(response: NSHTTPURLResponse, hasAcceptableStatusCode statusCodes: [Int]) -> Bool {
return contains(statusCodes, response.statusCode)
}

/**
Validates that the response has a status code in the specified range.

If validation fails, subsequent calls to response handlers will have an associated error.

:param: range The range of acceptable status codes.

:returns: The request.
*/
public func validate(statusCode range: Range<Int>) -> Self {
return validate { (_, response) in
return Request.response(response, hasAcceptableStatusCode: range.map({$0}))
}
}

/**
Validates that the response has a status code in the specified array.

If validation fails, subsequent calls to response handlers will have an associated error.

:param: array The acceptable status codes.

:returns: The request.
*/
public func validate(statusCode array: [Int]) -> Self {
return validate { (_, response) in
return Request.response(response, hasAcceptableStatusCode: array)
}
}

// MARK: Content-Type

private struct MIMEType {
let type: String
let subtype: String

init(_ string: String) {
let components = string.stringByTrimmingCharactersInSet(NSCharacterSet.whitespaceAndNewlineCharacterSet()).substringToIndex(string.rangeOfString(";")?.endIndex ?? string.endIndex).componentsSeparatedByString("/")

self.type = components.first!
self.subtype = components.last!
}

func matches(MIME: MIMEType) -> Bool {
switch (type, subtype) {
case ("*", "*"), ("*", MIME.subtype), (MIME.type, "*"), (MIME.type, MIME.subtype):
return true
default:
return false
}
}
}

private class func response(response: NSHTTPURLResponse, hasAcceptableContentType contentTypes: [String]) -> Bool {
if response.MIMEType != nil {
let responseMIMEType = MIMEType(response.MIMEType!)
for acceptableMIMEType in contentTypes.map({MIMEType($0)}) {
if acceptableMIMEType.matches(responseMIMEType) {
return true
}
}
}

return false
}

/**
Validates that the response has a content type in the specified array.

If validation fails, subsequent calls to response handlers will have an associated error.

:param: contentType The acceptable content types, which may specify wildcard types and/or subtypes.

:returns: The request.
*/
public func validate(contentType array: [String]) -> Self {
return validate {(_, response) in
return Request.response(response, hasAcceptableContentType: array)
}
}

// MARK: Automatic

/**
Validates that the response has a status code in the default acceptable range of 200...299, and that the content type matches any specified in the Accept HTTP header field.

If validation fails, subsequent calls to response handlers will have an associated error.

:returns: The request.
*/
public func validate() -> Self {
let acceptableStatusCodes: Range<Int> = 200..<300
let acceptableContentTypes: [String] = {
if let accept = self.request.valueForHTTPHeaderField("Accept") {
return accept.componentsSeparatedByString(",")
}

return ["*/*"]
}()

return validate(statusCode: acceptableStatusCodes).validate(contentType: acceptableContentTypes)
}
}

// MARK: - Upload

extension Manager {
Expand Down
46 changes: 24 additions & 22 deletions Tests/AuthenticationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,29 +30,30 @@ class AlamofireAuthenticationTestCase: XCTestCase {
let password = "password"
let URL = "http://httpbin.org/basic-auth/\(user)/\(password)"

let validCredentialsExpectation = expectationWithDescription("\(URL) 200")
let invalidCredentialsExpectation = expectationWithDescription("\(URL) 401")

Alamofire.request(.GET, URL)
.authenticate(user: user, password: password)
.authenticate(user: "invalid", password: "credentials")
.response { (request, response, _, error) in
validCredentialsExpectation.fulfill()
invalidCredentialsExpectation.fulfill()

XCTAssertNotNil(request, "request should not be nil")
XCTAssertNotNil(response, "response should not be nil")
XCTAssert(response?.statusCode == 200, "response status code should be 200")
XCTAssertNil(error, "error should be nil")
XCTAssertNil(response, "response should be nil")
XCTAssertNotNil(error, "error should not be nil")
XCTAssert(error?.code == -999, "error should be NSURLErrorDomain Code -999 'cancelled'")
}

let validCredentialsExpectation = expectationWithDescription("\(URL) 200")

Alamofire.request(.GET, URL)
.authenticate(user: "invalid", password: "credentials")
.authenticate(user: user, password: password)
.response { (request, response, _, error) in
invalidCredentialsExpectation.fulfill()
validCredentialsExpectation.fulfill()

XCTAssertNotNil(request, "request should not be nil")
XCTAssertNil(response, "response should be nil")
XCTAssertNotNil(error, "error should not be nil")
XCTAssert(error?.code == -999, "error should be NSURLErrorDomain Code -999 'cancelled'")
XCTAssertNotNil(response, "response should not be nil")
XCTAssert(response?.statusCode == 200, "response status code should be 200")
XCTAssertNil(error, "error should be nil")
}

waitForExpectationsWithTimeout(10) { (error) in
Expand All @@ -66,29 +67,30 @@ class AlamofireAuthenticationTestCase: XCTestCase {
let password = "password"
let URL = "http://httpbin.org/digest-auth/\(qop)/\(user)/\(password)"

let validCredentialsExpectation = expectationWithDescription("\(URL) 200")
let invalidCredentialsExpectation = expectationWithDescription("\(URL) 401")

Alamofire.request(.GET, URL)
.authenticate(user: user, password: password)
.authenticate(user: "invalid", password: "credentials")
.response { (request, response, _, error) in
validCredentialsExpectation.fulfill()
invalidCredentialsExpectation.fulfill()

XCTAssertNotNil(request, "request should not be nil")
XCTAssertNotNil(response, "response should not be nil")
XCTAssert(response?.statusCode == 200, "response status code should be 200")
XCTAssertNil(error, "error should be nil")
XCTAssertNil(response, "response should be nil")
XCTAssertNotNil(error, "error should not be nil")
XCTAssert(error?.code == -999, "error should be NSURLErrorDomain Code -999 'cancelled'")
}

let validCredentialsExpectation = expectationWithDescription("\(URL) 200")

Alamofire.request(.GET, URL)
.authenticate(user: "invalid", password: "credentials")
.authenticate(user: user, password: password)
.response { (request, response, _, error) in
invalidCredentialsExpectation.fulfill()
validCredentialsExpectation.fulfill()

XCTAssertNotNil(request, "request should not be nil")
XCTAssertNil(response, "response should be nil")
XCTAssertNotNil(error, "error should not be nil")
XCTAssert(error?.code == -999, "error should be NSURLErrorDomain Code -999 'cancelled'")
XCTAssertNotNil(response, "response should not be nil")
XCTAssert(response?.statusCode == 200, "response status code should be 200")
XCTAssertNil(error, "error should be nil")
}

waitForExpectationsWithTimeout(10) { (error) in
Expand Down
Loading

0 comments on commit 3040ba6

Please sign in to comment.