- Notifications
You must be signed in to change notification settings - Fork8
🌏 A zero-dependency networking solution for building modern and secure iOS, watchOS, macOS and tvOS applications.
License
billp/TermiNetwork
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
A zero-dependency networking solution for building modern and secure iOS, watchOS, macOS and tvOS applications.
🚀TermiNetwork has been tested in a production environment with a heavy load of asynchronous requests and tens of thousands of unique clients per day.
- Multi-environment setup
- Model deserialization withCodables
- Async await support
- Decodes response to the given type:Codable,Transformer,UIImage,Data orString
- UIKit/SwiftUI extensions for downloading remote images
- Request grouping with Repositories
- Detects network status with Reachability
- Transformers: convert models from one type to another easily
- Error Handling
- Interceptors
- Request mocking
- Certificate Pinning
- Flexible configuration
- Middleware support
- File/Data Upload/Download support
- Pretty printed debug information
- Installation
- Demo Application
- Usage
- Queue Hooks
- File/Data Upload
- File Download
- Error Handling
- Cancelling a Request
- Reachability
- Transformers
- Mock responses
- Interceptors
- Image Helpers
- Middleware
- Debug Logging
You can installTermiNetwork with one of the following ways...
Add the following line to yourPodfile and runpod install in your terminal:
pod'TermiNetwork','~> 4.0'
Add the following line to yourCarthage and runcarthage update in your terminal:
github"billp/TermiNetwork" ~>4.0
Go toFile >Swift Packages >Add Package Dependency and add the following URL:
https://github.com/billp/TermiNetwork
To see all the features of TermiNetwork in action, download the source code and run theTermiNetworkExamples scheme.
Let's say you have the following Codable model:
structTodo:Codable{letid:Intlettitle:String}
The following example creates a request that adds a new Todo:
letparams=["title":"Go shopping."]letheaders=["x-auth":"abcdef1234"]Request(method:.get,url:"https://myweb.com/api/todos",headers: headers,params: params).success(responseType:Todo.self){ todosinprint(todos)}.failure{ errorinprint(error.localizedDescription)}
or withasync await:
letrequest=Request(method:.get, url:"https://myweb.com/api/todos", headers: headers, params: params)do{lettodos:[Todo]=tryawait request.async()print(todos)}catchlet error{print(error.localizedDescription)}
One of the following supported HTTP methods:
.get, .head, .post, .put, .delete, .connect, .options, .trace or .patch
One of the following supported response types
Codable.self (implementations), UIImage.self, Data.self or String.self
A callback that returns an object of the given type. (specified in responseType parameter)
A callback that returns aError and the responseData (if any).
The following example uses a customQueue withmaxConcurrentOperationCount and a configuration object. To see the full list of available configuration properties, take a look atConfiguration properties in documentation.
letmyQueue=Queue(failureMode:.continue)myQueue.maxConcurrentOperationCount=2letconfiguration=Configuration( cachePolicy:.useProtocolCachePolicy, timeoutInterval:30, requestBodyType:.JSON)letparams=["title":"Go shopping."]letheaders=["x-auth":"abcdef1234"]Request(method:.post, url:"https://myweb.com/todos", headers: headers, params: params, configuration: configuration).queue(queue).success(responseType:String.self){ responseinprint(response)}.failure{ errorinprint(error.localizedDescription)}
or withasync await:
do{letresponse=tryRequest( method:.post,url:"https://myweb.com/todos",headers: headers,params: params,configuration: configuration).queue(queue).async(as:String.self)}catchlet error{print(error.localizedDescription)}
The request above uses a custom queuemyQueue with a failure mode of.continue (default), which means that the queue continues its execution if a request fails.
The complete and recommended setup of TermiNetwork consists of definingEnvironments andRepositories.
Create a swiftenum that implements theEnvironmentProtocol and define your environments.
enumMyAppEnvironments:EnvironmentProtocol{case developmentcase qafunc configure()->Environment{switchself{case.development:returnEnvironment(scheme:.https, host:"localhost", suffix:.path(["v1"]), port:3000)case.qa:returnEnvironment(scheme:.http, host:"myqaserver.com", suffix:.path(["v1"]))}}}
Optionally you can pass aconfiguration object to make all Repositories and Endpoints to inherit the given configuration settings.
To set your global environment use Environment.set method
Environment.set(MyAppEnvironments.development)
Create a swiftenum that implements theEndpointProtocol and define your endpoints.
The following example creates a TodosRepository with all the required endpoints as cases.
enumTodosRepository:EndpointProtocol{ // Define your endpointscase listcase show(id:Int)case add(title:String)case remove(id:Int)case setCompleted(id:Int, completed:Bool)staticletconfiguration=Configuration(requestBodyType:.JSON, headers:["x-auth":"abcdef1234"]) // Set method, path, params, headers for each endpointfunc configure()->EndpointConfiguration{switchself{case.list:return.init(method:.get, path:.path(["todos"]), // GET /todos configuration:Self.configuration)case.show(let id):return.init(method:.get, path:.path(["todo",String(id)]), // GET /todos/[id] configuration:Self.configuration)case.add(let title):return.init(method:.post, path:.path(["todos"]), // POST /todos params:["title": title], configuration:Self.configuration)case.remove(let id):return.init(method:.delete, path:.path(["todo",String(id)]), // DELETE /todo/[id] configuration: configuration)case.setCompleted(let id,let completed):return.init(method:.patch, path:.path(["todo",String(id)]), // PATCH /todo/[id] params:["completed": completed], configuration: configuration)}}}
You can optionally pass aconfiguration object to each case if you want provide different configuration for each endpoint.
To create the request you have to initialize aClient instance and specialize it with your defined Repository, in our caseTodosRepository:
Client<TodosRepository>().request(for:.add(title:"Go shopping!")).success(responseType:Todo.self){ todoin // do something with todo}.failure{ errorin // do something with error}
or withasync await
do{lettoto:Todo=Client<TodosRepository>().request(for:.add(title:"Go shopping!")).async()}catchlet error{print(error.localizedDescription)}
Hooks are closures that run before and/or after a request execution in a queue. The following hooks are available:
Queue.shared.beforeAllRequestsCallback={ // e.g. show progress loader}Queue.shared.afterAllRequestsCallback={ completedWithErrorin // e.g. hide progress loader}Queue.shared.beforeEachRequestCallback={ requestin // do something with request}Queue.shared.afterEachRequestCallback={ request, data, urlResponse, error // do something with request, data, urlResponse, error}
For more information take a look atQueue in documentation.
You can use the.upload,.asyncUpload methods of a Request object to start an upload operation. The upload is perfomed by passing a Content-Type: multipart/form-data request header. All the param values should be passed as MultipartFormDataPartType.
do{tryawaitRequest(method:.post, url:"https://mywebsite.com/upload", params:["file1":MultipartFormDataPartType.url(.init(filePath:"/path/to/file.zip")),"file2":MultipartFormDataPartType.data(data:Data(), filename:"test.png", contentType:"zip"),"expiration_date":MultipartFormDataPartType.value(value:Date.now.ISO8601Format())]).asyncUpload(as:ResponseModel.self){ _, _, progressindebugPrint("\(progress*100)% completed")} debugPrint("Upload finished)} catch let error { debugPrint(error)}
You can use the.download,.asyncDownload methods of a Request object to start a download operation. The only thing you need to pass is the local file path of the file to be saved.
guardvar localFile=FileManager.default.urls(for:.cachesDirectory, in:.userDomainMask).first.appendPathComponent("download.zip")else{return}do{tryawaitRequest(method:.get, url:"https://mywebsite.com/files/download.zip").asyncDownload(destinationPath: localFile.path, progressUpdate:{ bytesSent, totalBytes, progressindebugPrint("\(progress*100)% completed")})}catchlet error{debugPrint(error.localizedDescription)}debugPrint("File saved to:\(localFile.path)")
TermiNetwork provides its own error types (TNError) for all the possible error cases. These errors are typically returned in onFailure callbacks ofstart methods.
To see all the available errors, please visit theTNError in documentation.
Client<TodosRepository>().request(for:.add(title:"Go shopping!")).success(responseType:Todo.self){ todoin // do something with todo}.failure:{ error inswitch error{case.notSuccess(let statusCode):debugPrint("Status code"+ String(statusCode))breakcase.networkError(let error):debugPrint("Network error:"+ error.localizedDescription)breakcase.cancelled:debugPrint("Request cancelled")breakdefault:debugPrint("Error:"+ error.localizedDescription)}
or withasync await
do{lettodo:Todo=Client<TodosRepository>().request(for:.add(title:"Go shopping!")).async()}catchlet error{switch erroras?TNError{case.notSuccess(let statusCode,let data):leterrorModel=try? data.deserializeJSONData()asMyErrorModeldebugPrint("Status code"+ String(statusCode)+". API Error:"+ errorModel?.errorMessage)breakcase.networkError(let error):debugPrint("Network error:"+ error.localizedDescription)breakcase.cancelled:debugPrint("Request cancelled")breakdefault:debugPrint("Error:"+ error.localizedDescription)}
You can cancel a request that is executing by calling the .cancel() method.
letparams=["title":"Go shopping."]letheaders=["x-auth":"abcdef1234"]letrequest=Request(method:.get, url:"https://myweb.com/api/todos", headers: headers, params: params)request.success(responseType:Todo.self){ todosinprint(todos)}.failure{ errorinprint(error.localizedDescription)}request.cancel()
or withasync await:
lettask=Task{letrequest=Request(method:.get, url:"https://myweb.com/api/todos", headers: headers, params: params)do{lettodos:[Todo]=tryawait request.async()print(todos)}catchlet error{print(error.localizedDescription)}}task.cancel()
With Reachability you can monitor the network state of the device, like whether it is connected through wifi or cellular network.
letreachability=Reachability()try? reachability.monitorState{ stateinswitch state{case.wifi: // Connected through wifi case.cellular: // Connected through cellular networkcase.unavailable: // No connection}}
Transformers enables you to convert your Rest models to Domain models by defining your customtransform functions. To do so, you have to create a class that inherits theTransformer class and specializing it by providing the FromType and ToType generics.
The following example transforms an array ofRSCity (rest) to an array ofCity (domain) by overriding the transform function.
finalclassCitiesTransformer:Transformer<[RSCity],[City]>{overridefunc transform(_ object:[RSCity])throws->[City]{ object.map{ rsCityinCity(id:UUID(), cityID: rsCity.id, name: rsCity.name, description: rsCity.description, countryName: rsCity.countryName, thumb: rsCity.thumb, image: rsCity.image)}}}
Finally, pass theCitiesTransformer in the Request's start method:
Client<CitiesRepository>().request(for:.cities).success(transformer:CitiesTransformer.self){ citiesinself.cities= cities}.failure{ errorinswitch error{case.cancelled:breakdefault:self.errorMessage= error.localizedDescription}}
or withasync await
do{letcities=awaitClient<CitiesRepository>().request(for:.cities).async(using:CitiesTransformer.self)}catchlet error{switch erroras?TNError{case.cancelled:breakdefault:self.errorMessage= error.localizedDescription}}
Mock responses is a powerful feature of TermiNetwork that enables you to provide a local resource file as Request's response. This is useful, for example, when the API service is not yet available and you need to implement the app's functionality without losing any time. (Prerequisite for this is to have an API contract)
- Create a Bundle resource and put your files there. (File > New -> File... > Settings Bundle)
- Specify the Bundle path in Configuration
letconfiguration=Configuration()iflet path=Bundle.main.path(forResource:"MockData", ofType:"bundle"){ configuration.mockDataBundle=Bundle(path: path)}
- Enable Mock responses in Configuration
configuration.mockDataEnabled=true
- Define the mockFilePath path in your endpoints.The example above loads theCities/cities.json fromMockData.bundle and returns its data as Request's response.
enumCitiesRepository:EndpointProtocol{case citiesfunc configure()->EndpointConfiguration{switchself{case.cities:returnEndpointConfiguration(method:.get, path:.path(["cities"]), mockFilePath:.path(["Cities","cities.json"]))}}}
For a complete example, open the demo application and take a look atCity Explorer - Offline Mode.
Interceptors offers you a way to change or augment the usual processing cycle of a Request. For instance, you can refresh an expired access token (unauthorized status code 401) and then retry the original request. To do so, you just have to implement theInterceptorProtocol.
The following Interceptor implementation tries to refresh the access token with a retry limit (5).
finalclassUnauthorizedInterceptor:InterceptorProtocol{letretryDelay:TimeInterval=0.1letretryLimit=5func requestFinished(responseData data:Data?, error:TNError?, request:Request, proceed:@escaping(InterceptionAction)->Void){switch error{case.notSuccess(let statusCode):if statusCode==401, request.retryCount< retryLimit{ // Login and get a new token.Request(method:.post, url:"https://www.myserviceapi.com/login", params:["username":"johndoe","password":"p@44w0rd"]).success(responseType:LoginResponse.self){ responseinletauthorizationValue=String(format:"Bearer %@", response.token) // Update the global header in configuration which is inherited by all requests.Environment.current.configuration?.headers["Authorization"]= authorizationValue // Update current request's header. request.headers["Authorization"]= authorizationValue // Finally retry the original request.proceed(.retry(delay: retryDelay))}}else{ // Continue if the retry limit is reachedproceed(.continue)}default:proceed(.continue)}}}
Finally, you have to pass theUnauthorizedInterceptor to the interceptors property in Configuration:
letconfiguration=Configuration()configuration.interceptors=[UnauthorizedInterceptor.self]
TermiNetwork provides two different helpers for setting remote images.
Example with URL
varbody:someView{TermiNetwork.Image(withURL:"https://example.com/path/to/image.png", defaultImage:UIImage(named:"DefaultThumbImage"))}
Example with Request
varbody:someView{TermiNetwork.Image(withRequest:Client<CitiesRepository>().request(for:.image(city: city)), defaultImage:UIImage(named:"DefaultThumbImage"))}
Example with URL
letimageView=UIImageView() // or NSImageView (macOS), or WKInterfaceImage (watchOS)imageView.tn_setRemoteImage(url: sampleImageURL, defaultImage:UIImage(named:"DefaultThumbImage"), preprocessImage:{ imagein // Optionally pre-process image and return the new image.return image}, onFinish:{ image, errorin // Optionally handle response})
Example with Request
letimageView=UIImageView() // or NSImageView (macOS), or WKInterfaceImage (watchOS)imageView.tn_setRemoteImage(request:Client<CitiesRepository>().request(for:.thumb(withID:"3125")), defaultImage:UIImage(named:"DefaultThumbImage"), preprocessImage:{ imagein // Optionally pre-process image and return the new image.return image}, onFinish:{ image, errorin // Optionally handle response})
Middleware enables you to modify headers, params and response before they reach the success/failure callbacks. You can create your own middleware by implementing theRequestMiddlewareProtocol and passing it to aConfiguration object.
Take a look at./Examples/Communication/Middleware/CryptoMiddleware.swift for an example that adds an additional encryption layer to the application.
You can enable the debug logging by setting theverbose property totrue in yourConfiguration.
letconfiguration=Configuration()configuration.verbose=true
... and you will see a beautiful pretty-printed debug output in debug window
To run the tests open the Xcode Project > TermiNetwork scheme, select Product -> Test or simply press ⌘U on keyboard.
Alex Athanasiadis,alexanderathan@gmail.com
TermiNetwork is available under the MIT license. See the LICENSE file for more info.
About
🌏 A zero-dependency networking solution for building modern and secure iOS, watchOS, macOS and tvOS applications.