How to Build a Proxy on iOS
In this tutorial, Iโll show you how to use the versatile URLProtocol
class to intercept network requests inside your app, independently of whether the request originated from a URLSession
, a wrapper library such as Alamofire, an NSURLConnection
, or an Ajax request inside a web view.
Real-world applications include:
- Proxies
- Stub HTTP requests for testing (see Mockingjay)
- Network activity indicators (see Big Brother)
- Certificate pinning
Laying the Groundwork ๐ท
To get started, create a new Single View Application in Xcode, and select Swift as the language.
Replace the contents of ViewController.swift
with the following:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let url = URL(string: "https://httpbin.org/post")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try? JSONSerialization.data(withJSONObject: [ "hello": "world" ], options: [])
URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in
guard let data = data else { return }
guard let str = String(data: data, encoding: .utf8) else { return }
print(str)
}).resume()
}
}
The above code posts {"hello": "world"}
(a JSON object) to a public test endpoint that simply echos the data back, along with a few other bits of information. Build and run the project, you should see something like this in your console (note that the json
attribute contains the sent JSON):
{
"args": {},
"data": "{\"hello\":\"world\"}",
"files": {},
"form": {},
"headers": {
"Accept": "application/json",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "en-us",
"Content-Length": "17",
"Content-Type": "application/json",
"Host": "httpbin.org",
"User-Agent": "ProxyApp/1.0 CFNetwork/811.5.4 Darwin/16.6.0 (x86_64)"
},
"json": {
"hello": "world"
},
"origin": "186.23.16.226",
"url": "https://httpbin.org/post"
}
Great! Now that we have a simple HTTP request working, letโs create a custom URLProtocol
to intercept it.
Building the Custom URLProtocol ๐ง
Create a new Swift file called ProxyURLProtocol.swift
and add the following code:
import Foundation
class ProxyURLProtocol: URLProtocol {
// MARK: - URLProtocol Implementation
override class func canInit(with request: URLRequest) -> Bool {
// Only handle HTTP and HTTPS requests
guard let scheme = request.url?.scheme?.lowercased() else {
return false
}
return scheme == "http" || scheme == "https"
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
override func startLoading() {
// Log the original request
print("๐ Intercepted request: \(request.url?.absoluteString ?? "unknown")")
print(" Method: \(request.httpMethod ?? "GET")")
print(" Headers: \(request.allHTTPHeaderFields ?? [:])")
// Create a new session to perform the actual request
let session = URLSession(configuration: .default)
let task = session.dataTask(with: request) { [weak self] data, response, error in
guard let self = self else { return }
if let error = error {
self.client?.urlProtocol(self, didFailWithError: error)
} else {
if let response = response {
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
}
if let data = data {
print("๐ฆ Response data size: \(data.count) bytes")
self.client?.urlProtocol(self, didLoad: data)
}
self.client?.urlProtocolDidFinishLoading(self)
}
}
task.resume()
}
override func stopLoading() {
// Clean up if needed
}
}
This custom URLProtocol
does several important things:
canInit(with:)
- Determines which requests this protocol should handle. Weโre only intercepting HTTP/HTTPS requests.canonicalRequest(for:)
- Returns the canonical version of the request. For our proxy, we return the request unchanged.startLoading()
- This is where the magic happens. We log the request details, create a newURLSession
to perform the actual network call, and forward the response back to the original caller.stopLoading()
- Clean up any resources when the request is cancelled.
Registering the Protocol ๐
Now we need to register our custom protocol with the URL loading system. Update your ViewController.swift
:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Register our custom protocol
URLProtocol.registerClass(ProxyURLProtocol.self)
let url = URL(string: "https://httpbin.org/post")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try? JSONSerialization.data(withJSONObject: [ "hello": "world" ], options: [])
URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in
guard let data = data else { return }
guard let str = String(data: data, encoding: .utf8) else { return }
print("โ
Final response: \(str)")
}).resume()
}
}
Run the app again, and you should see the proxy logging output in the console:
๐ Intercepted request: https://httpbin.org/post
Method: POST
Headers: ["Accept": "application/json", "Content-Type": "application/json"]
๐ฆ Response data size: 426 bytes
โ
Final response: {
"args": {},
"data": "{\"hello\":\"world\"}",
...
}
Perfect! Our proxy is now intercepting and logging all network requests.
Advanced Use Cases ๐
Now that we have the basic proxy working, letโs explore some advanced use cases:
1. Request Modification
You can modify requests before theyโre sent:
override func startLoading() {
// Create a mutable copy of the request
let mutableRequest = request.mutableCopy() as! NSMutableURLRequest
// Add custom headers
mutableRequest.setValue("ProxyApp/1.0", forHTTPHeaderField: "User-Agent")
mutableRequest.setValue("Bearer your-token-here", forHTTPHeaderField: "Authorization")
print("๐ Modified request: \(mutableRequest.url?.absoluteString ?? "unknown")")
// Use the modified request
let session = URLSession(configuration: .default)
let task = session.dataTask(with: mutableRequest as URLRequest) { [weak self] data, response, error in
// ... rest of the implementation
}
task.resume()
}
2. Response Modification
You can also modify responses before they reach your app:
override func startLoading() {
let session = URLSession(configuration: .default)
let task = session.dataTask(with: request) { [weak self] data, response, error in
guard let self = self else { return }
if let error = error {
self.client?.urlProtocol(self, didFailWithError: error)
} else {
if let response = response {
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
}
if var data = data {
// Modify the response data
if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
var modifiedJson = jsonObject
modifiedJson["proxy_timestamp"] = Date().timeIntervalSince1970
if let modifiedData = try? JSONSerialization.data(withJSONObject: modifiedJson, options: []) {
data = modifiedData
}
}
print("๐ฆ Modified response data size: \(data.count) bytes")
self.client?.urlProtocol(self, didLoad: data)
}
self.client?.urlProtocolDidFinishLoading(self)
}
}
task.resume()
}
3. Conditional Interception
You might want to only intercept certain requests:
override class func canInit(with request: URLRequest) -> Bool {
// Only intercept requests to specific domains
guard let host = request.url?.host else { return false }
let targetHosts = ["api.myapp.com", "httpbin.org"]
return targetHosts.contains(host)
}
4. Request Caching
Implement custom caching logic:
class CachingProxyURLProtocol: URLProtocol {
static var cache: [String: Data] = [:]
override func startLoading() {
let cacheKey = request.url?.absoluteString ?? ""
// Check cache first
if let cachedData = CachingProxyURLProtocol.cache[cacheKey] {
print("๐ Serving from cache: \(cacheKey)")
// Create a mock response
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: cachedData)
client?.urlProtocolDidFinishLoading(self)
return
}
// Not in cache, make the request
let session = URLSession(configuration: .default)
let task = session.dataTask(with: request) { [weak self] data, response, error in
guard let self = self else { return }
if let error = error {
self.client?.urlProtocol(self, didFailWithError: error)
} else {
if let response = response {
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
}
if let data = data {
// Cache the response
CachingProxyURLProtocol.cache[cacheKey] = data
print("๐พ Cached response for: \(cacheKey)")
self.client?.urlProtocol(self, didLoad: data)
}
self.client?.urlProtocolDidFinishLoading(self)
}
}
task.resume()
}
}
Testing and Debugging ๐
URLProtocol
is particularly useful for testing:
class MockURLProtocol: URLProtocol {
static var mockData: [String: Data] = [:]
static var mockError: Error?
override class func canInit(with request: URLRequest) -> Bool {
return true // Handle all requests in test mode
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
override func startLoading() {
let url = request.url?.absoluteString ?? ""
// Check if we should return an error
if let error = MockURLProtocol.mockError {
client?.urlProtocol(self, didFailWithError: error)
return
}
// Return mock data if available
if let data = MockURLProtocol.mockData[url] {
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} else {
// Return empty response
let response = HTTPURLResponse(url: request.url!, statusCode: 404, httpVersion: nil, headerFields: nil)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocolDidFinishLoading(self)
}
}
override func stopLoading() {
// Nothing to do
}
}
// In your test setup:
MockURLProtocol.mockData["https://api.myapp.com/users"] = """
{"users": [{"id": 1, "name": "Test User"}]}
""".data(using: .utf8)!
URLProtocol.registerClass(MockURLProtocol.self)
Key Takeaways ๐ฏ
-
URLProtocol
is powerful: It can intercept any network request made by your app, regardless of the networking library used. -
Registration order matters: The last registered protocol is checked first. If multiple protocols can handle the same request, the most recently registered one wins.
-
Donโt forget to unregister: Use
URLProtocol.unregisterClass()
when you no longer need the protocol, especially in test scenarios. -
Be careful with infinite loops: If your protocol makes network requests, make sure they donโt get intercepted by the same protocol again.
-
Performance considerations: Interception adds overhead. Only intercept requests when necessary.
Conclusion ๐
URLProtocol
is a versatile tool that opens up many possibilities for network handling in iOS apps. Whether youโre building a proxy, implementing custom caching, stubbing requests for testing, or adding network monitoring, URLProtocol
provides the foundation you need.
The complete source code for this tutorial is available on GitHub.
What will you build with URLProtocol
? Let me know in the comments below!
This post was originally published in 2017 and demonstrates networking concepts that remain relevant for modern iOS development.