Building an iOS app that fetches data from a network? With Teller, you can add a cache to your iOS app in minutes! Faster apps for a better user experience.
Read the official announcement of Teller to learn more about what it does and why to use it.
Android developer? Check out the Android version of Teller.
Version 1.0 has been announced and is in development. Check out the issue to learn more. The pre-1.0 code will be in maintenance mode until v1.0 is complete. Pre-1.0 is used by myself in production apps today, but you have been warned that there will more then likely be large breaking changes when 1.0 comes out.
Caching the data that your app fetches from a network call can make your app much more enjoyable to use.
However, adding a cache to your app takes work. You need to...
Teller takes care of all of the tasks above except #1 (that part if your job). All you need to do is tell Teller how to fetch, save, and query the device cache and Teller takes care of the rest.
This allows you to add a cache to your app within minutes without the boilerplate.
Teller is designed for developers building offline-first mobile apps. If you are someone looking to build an offline-first mobile app, also be sure to checkout Wendy (there is an Android version too). Wendy is designed to sync your device's cached data with remote storage. Think of it like this: Teller is really good at GET
calls for your network API, Wendy is really good at PUT, POST, DELETE
network API calls. Teller pulls data, Wendy pushes data. These 2 libraries work really nicely together!
Not only does Teller help you add a cache to your app quickly and easily, Teller also allows you to make your app more transparent to your users. You will easily be able to tell your users...
When you add a cache to your app it is important to be transparent about the cache so the user understands the state of the cache. Teller handles all of this for you.
There are also some other benefits of Teller:
Teller is available through CocoaPods. To install it, simply add the following line to your Podfile:
pod 'Teller', '~> version-here'
Replace version-here
with: as this is the latest version at this time.
Note: Teller is under development. Even though it is used in production in my own apps, the code base can change at anytime.
After using Teller for a handful of years now, I have been able to mature the library as time goes on. The API is still considered Alpha as it may encounter drastic changes in the future.
Teller is designed with 1 goal in mind: Help you add cache support to your app quickly and easily. You help Teller understand where the cache is saved, how to get it, and Teller takes care of the rest. Let's get going.
Note: If you're looking to using pagination with Teller, read this getting started guide first and then read the pagination section to learn how to do that.
RepositoryDataSource
. Here is an example.
import Foundation
import Teller
import RxSwift
import Moya
class ReposRequirements: RepositoryRequirements {
/**
The tag is used to determine how old your cache is. Teller uses this to determine if a fresh cache needs to be fetched or not. If the tag matches previously cached data of the same tag, the data that data was fetched will be queried and determined if it's considered too old and will fetch fresh data or not from the result of the compare.
The best practice is to describe what the cache represents. "Repos for <username>" is a great example.
*/
var tag: ReposRepositoryRequirements.Tag {
return "Repos for \(username)"
}
let username: String
init(username: String) {
self.username = username
}
}
// Struct used to represent the JSON data pulled from the GitHub API.
struct Repo: Codable {
var id: Int!
var name: String!
}
class ReposRepositoryDataSource: RepositoryDataSource {
typealias Cache = [Repo]
typealias Requirements = ReposRepositoryRequirements
typealias FetchResult = [Repo]
typealias FetchError = Error
// How old a cache can be before it's considered old and an automatic refresh should be performed.
// Teller tries to reduce the number of network calls performed to save on bandwidth of your user's device.
var maxAgeOfCache: Period = Period(unit: 5, component: .hour)
func fetchFreshCache(requirements: Requirements) -> Single<FetchResponse<Cache, FetchError>> {
// Return network call that returns a RxSwift Single.
// The project Moya (https://github.com/moya/moya) is my favorite library to do this.
return MoyaProvider<GitHubService>().rx.request(.listRepos(user: requirements.username))
.map({ (response) -> FetchResponse<[Repo]> in
let repos = try! JSONDecoder().decode([Repo].self, from: response.data)
// If there was a failure, use FetchResponse.failure(Error) and the error will be sent to your user in the UI
return FetchResponse.success(data: repos)
})
}
// Note: Teller runs this function from a background thread.
func saveCache(_ fetchedData: Cache, requirements: Requirements) throws {
// Save data to CoreData, Realm, UserDefaults, File, whatever you wish here.
// If there is an error, you may throw it, and have it get passed to the observer of the Repository.
}
// Note: Teller runs this function from the UI thread
func observeCache(requirements: Requirements) -> Observable<Cache> {
// Return Observable that is observing the cached data.
//
// When any of the repos in the database have been changed, we want to trigger an Observable update.
// Teller may call `observeCachedData` regularly to keep data fresh.
return Observable.just([])
}
// Note: Teller runs this function from the same thread as `observeCachedData()`
func isCacheEmpty(_ cache: Cache, requirements: Requirements) -> Bool {
return cache.isEmpty
}
}
TellerRepository
instance.let disposeBag = DisposeBag()
let repository = TellerRepository(dataSource: ReposRepositoryDataSource())
repository.requirements = ReposRepositoryDataSource.Requirements(username: "username to get repos for")
repository
.observe()
.observeOn(ConcurrentDispatchQueueScheduler(qos: .background))
.subscribeOn(MainScheduler.instance)
.subscribe(onNext: { (cacheState: CacheState<[Repo]>) in
// Teller provides a handy way to parse the `CacheState` to understand the state your cache is in.
switch cacheState.state {
// No cache exists. A successful network request has not happened yet.
case .noCache:
// Repos have never been fetched before for the GitHub user.
break
case .cache(let cache, let cacheAge):
// Repos have been fetched before for the GitHub user.
let isCacheEmpty = cache == nil // If `cache` is nil, the cache is empty.
// Use `cacheAge` in your UI to tell the user how long ago the last successful network request was.
}
// You can inspect a lot more about the state of your cache.
cacheState.isRefreshing // If a network request is happening right now to refresh the cache
cacheState.justFinishedFirstFetch // The first successful network call just finished
cacheState.refreshError // Get error from network call if there was one during refresh
// ... and more. Use these properties in the UI of your app to be transparent about your cache!
})
.disposed(by: disposeBag)
In order for Teller to do it's magic, you need to (1) initialize the requirements
property and (2) observe()
the TellerRepository
instance. This gives Teller the information it needs to begin. If you forget to set requirements
, nothing will happen when you observe()
.
Done! You are using Teller! Continue reading this document to learn about the advanced
It's assumed that you have read the getting started section. This section will build on top of that.
Teller does all of the hard work for adding pagination to your app's cache. All you need to do is take what you learned about how to use Teller thus far and add a few more functions.
PagingRepositoryDataSource
. This example will build upon the DataSource in the getting started section.// `PagingRepositoryRequirements` is a special object used to understand how to fetch pages of data from a network.
// Some APIs you work with might
// ...use a page number, like GitHub: https://developer.github.com/v3/guides/traversing-with-pagination/
// ...use an ID of your last retrieved item, like SoundCloud https://developers.soundcloud.com/docs/api/guide#pagination
// ...use an ID of the first and last retrieved item, like Twitter https://developer.twitter.com/en/docs/ads/general/guides/pagination
//
// Whatever your API uses, you put those properties in this object to keep track of what page you are viewing now.
struct ReposPagingRequirements: PagingRepositoryRequirements {
let pageNumber: Int
func nextPage() -> ReposPagingRequirements {
return ReposPagingRequirements(pageNumber: pageNumber + 1)
}
}
class ReposRepositoryDataSource: PagingRepositoryDataSource {
// The data type your cache is. What `observe()` will use.
typealias PagingCache = [Repo]
// `RepositoryRequirements` subclass you're using
typealias Requirements = ReposRepositoryRequirements
// `PagingRepositoryRequirements` subclass you're using
typealias PagingRequirements = ReposPagingRequirements
// If you're using an API like Twitter or SoundCloud where future network calls depend
// data discovered from the previous network call, this field takes care of that.
// Use an Int, String, Tuple, Struct, etc for this.
// The GitHub API will simply go to the next page number so this field is not used.
typealias NextPageRequirements = Void
// The data type returned from network calls.
typealias PagingFetchResult = [Repo]
// Use a custom Error for network calls in your fetch calls.
typealias FetchError = Error
static let reposPageSize = 50
// You can use whatever method you wish for performing a HTTP network call. Moya is used in this example.
let moyaProvider = MoyaProvider<GitHubService>(plugins: [HttpLoggerMoyaPlugin()])
let keyValueStorage = UserDefaultsKeyValueStorage(userDefaults: UserDefaults.standard)
var maxAgeOfCache: Period = Period(unit: 5, component: .hour)
var currentRepos: [Repo] {
guard let currentReposData = keyValueStorage.string(forKey: .repos)?.data else {
return []
}
return try! JSONDecoder().decode([Repo].self, from: currentReposData)
}
// When you call `goToNextPage()` on your `TellerPagingRepository`, this function is called to get a new `PagingRequirements` for the next network call.
func getNextPagePagingRequirements(currentPagingRequirements: PagingRequirements, nextPageRequirements: NextPageRequirements?) -> PagingRequirements {
return currentPagingRequirements.nextPage()
}
// Teller will call this automatically when it needs. You need to delete all of your cache for the given `Requirements`.
// Note: Teller runs this function from a background thread.
func deleteCache(_ requirements: Requirements) {
keyValueStorage.delete(key: .repos)
}
// Teller will call this automatically when it needs. You need to delete all of your cache for the given `Requirements` *except* for the first page of cache.
// Note: Teller runs this function from a background thread.
func persistOnlyFirstPage(requirements: ReposRepositoryRequirements) {
let currentRepos = self.currentRepos
guard currentRepos.count > ReposRepositoryDataSource.reposPageSize else {
return
}
let firstPageRepos = Array(currentRepos[0...ReposRepositoryDataSource.reposPageSize])
keyValueStorage.setString((try! JSONEncoder().encode(firstPageRepos)).string!, forKey: .repos)
}
// The network call function has changed in the return type that you return.
func fetchFreshCache(requirements: ReposRepositoryRequirements, pagingRequirements: PagingRequirements) -> Single<FetchResponse<FetchResult, Error>> {
// Return network call that returns a RxSwift Single.
// The project Moya (https://github.com/moya/moya) is my favorite library to do this.
return moyaProvider.rx.request(.listRepos(user: requirements.username, pageNumber: pagingRequirements.pageNumber))
.map { (response) -> FetchResponse<FetchResult, FetchError> in
let repos = try! JSONDecoder().decode([Repo].self, from: response.data)
let responseHeaders = response.response!.allHeaderFields
let paginationNext = responseHeaders["link"] as? String ?? responseHeaders["Link"] as! String
let areMorePagesAvailable = paginationNext.contains("rel=\"next\"")
// When using pagination, Teller requires your fetch function to return more information regarding the network calls.
// You need to determine if there are more pages to fetch, or not.
// Also, populate `nextPageRequirements` with whatever you want that will get passed to `getNextPagePagingRequirements` when you call `goToNextPage()`.
//
// If there was a failure, use FetchResponse.failure(Error) and the error will be sent to your user in the UI
return FetchResponse.success(PagedFetchResponse(areMorePages: areMorePagesAvailable, nextPageRequirements: nil, fetchResponse: repos))
}
}
// Save the cache. Friendly reminder to *append* the new cache to storage. You don't want to replace the cache you already have as pagination builds on top of each other.
// Note: Teller runs this function from a background thread.
func saveCache(_ cache: [Repo], requirements: ReposRepositoryRequirements, pagingRequirements: PagingRequirements) throws {
// Save data to CoreData, Realm, UserDefaults, File, whatever you wish here.
// If there is an error, you may throw it, and have it get passed to the observer of the Repository.
var combinedRepos = currentRepos
combinedRepos.append(contentsOf: cache)
keyValueStorage.setString((try! JSONEncoder().encode(combinedRepos)).string!, forKey: .repos)
}
// This function has not changed from the getting started guide.
// Note: Teller runs this function from the UI thread
func observeCache(requirements: ReposRepositoryRequirements, pagingRequirements: ReposPagingRequirements) -> Observable<PagingCache> {
// Return Observable that is observing the cached data.
//
// When any of the repos in the database have been changed, we want to trigger an Observable update.
// Teller may call `observeCachedData` regularly to keep data fresh.
return keyValueStorage.observeString(forKey: .repos)
.map { (string) -> PagingCache in
try! JSONDecoder().decode([Repo].self, from: string.data!)
}
}
// This function has not changed from the getting started guide.
// Note: Teller runs this function from the same thread as `observeCachedData()`
func isCacheEmpty(_ cache: [Repo], requirements: ReposRepositoryRequirements, pagingRequirements: ReposPagingRequirements) -> Bool {
return cache.isEmpty
}
}
TellerPagingRepository
instance.let disposeBag = DisposeBag()
let repository = TellerPagingRepository(dataSource: ReposRepositoryDataSource(), firstPageRequirements: ReposRepositoryDataSource.PagingRequirements(pageNumber: 1))
let reposGetDataRequirements = ReposRepositoryDataSource.Requirements(username: "username to get repos for")
repository.requirements = reposGetDataRequirements
repository
.observe()
.observeOn(ConcurrentDispatchQueueScheduler(qos: .background))
.subscribeOn(MainScheduler.instance)
.subscribe(onNext: { (dataState: CacheState<PagedCache<[Repo]>>) in
switch dataState.state {
case .noCache:
// Repos have never been fetched before for the GitHub user.
break
case .cache(let cache, let cacheAge):
// Repos have been fetched before for the GitHub user.
// If `cache` is nil, the cache is empty.
if let pagedCache = cache {
let repositories = pagedCache.cache
let areMorePages = pagedCache.areMorePages
// Show/hide a "Loading more" footer in your UITableView by `areMorePages` value.
} else {
// The cache is empty! There are no repos for that particular username.
// Display a view in your app that tells the user there are no repositories to show.
}
// Access all of the properties you're used to when not using pagination:
cacheState.isRefreshing
}
})
.disposed(by: disposeBag)
// When the `UITableView` is scrolled down to the bottom, do...
repository.goToNextPage()
// ...and the next page of cache will be fetched!
Here are some references for you if you need more help with paging.
UITableView
is scrolled to the bottom of the list to know when to load the next page of the cache. UITableView
]() to notify the users of your app that you are loading more data. Use cache.areMorePages
like shown in this doc to determine when to hide/show this loading view. When users open up your app, they want to see fresh data. Not data that is out dated. To do this, it's best to perform background refreshes while your app is in the background.
Teller provides a simple method to refresh your TellerRepository
's cache while in the background. Run this function as often as you wish. Teller will only perform a new fetch for fresh cache is the cache is outdated.
let repository = TellerRepository(dataSource: ReposRepositoryDataSource())
repository.requirements = ReposRepositoryDataSource.Requirements(username: "username to get repos for")
try! repository.refresh(force: false)
.subscribe()
Note: You can use the Background app refresh feature in iOS to run refresh
on a set of TellerRepository
s periodically.
If your app will not function without a cache, use the convenient refreshIfNoCache()
function to perform a refresh()
call only if a cache does not already exist for that data source. This is great when your app first starts up after fresh install to download a cache to make your app function.
Call refreshIfNoCache()
and when the response is .successful
, you know that a cache exists. .successful
will be returned instantly if a cache already exists or asynchronously after a successful refresh is complete and the cache exists.
let repository = TellerRepository(dataSource: ReposRepositoryDataSource())
repository.requirements = ReposRepositoryDataSource.Requirements(username: "username to get repos for")
try! repository.refreshIfNoCache()
.subscribe(onSuccess: { (refreshResult) in
if case .successful = refreshResult {
// Cache does exist.
} else {
// Cache does not exist. View the error in `refreshResult` to see why the refresh attempt failed.
}
})
Note: A cache existing does not determine if a cache is empty or not. A cache exists if it has been successfully fetched at least 1 time before.
Do you have a UITableView
with pull-to-refresh enabled? Do you have a refresh button in your UINavigationBar
that you want your users to refresh the data when it's pressed?
No problem. Tell your Teller TellerRepository
instance to force refresh:
let repository = TellerRepository(dataSource: ReposRepositoryDataSource())
repository.requirements = ReposRepositoryDataSource.Requirements(username: "username to get repos for")
repository.refresh(force: true)
.subscribe()
If you ever find yourself with an instance of CacheState<A>
and you want to convert it to type CacheState<B>
, this is what you do:
repository.observe()
.map { (dataState) -> CacheState<B> in
dataState.convert { (a) -> B? in
guard let a = a else { return nil }
B(a: a)
}
}
Pretty simple. When you observe()
a TellerRepository
, call convert()
on the instance of DataState
to change to a different cache type.
One of Teller's conveniences is that it performs TellerRepository.refresh(force: false)
(notice the automatic refresh is not forced to respect the maxAgeOfCache
to keep network calls to a minimum) calls for you periodically in times such as (1) when new requirements is set on an instance of RepositoryDataSource
, (2) TellerRepository.observe()
is called, or (3) a cache update is triggered from the RepositoryDataSource
. This is convenient as it helps keep the cache always up-to-date.
Because this is convenient, Teller enabled this functionality by default. However, if you wish to disable this feature, you can do so in your RepositoryDataSource
:
class ReposRepositoryDataSource: RepositoryDataSource {
// override default value.
var automaticallyRefresh: Bool {
return false
}
}
It's recommended to keep the default functionality of enabling this feature. However, sometimes you may need control of how often network calls are performed. These scenarios are the scenarios when you would disable this feature.
Note: It's your responsibility to keep your RepositoryDataSource
's cache up-to-date by manually calling TellerRepository.refresh()
periodically in your app if you decide to disable this automatic refresh feature.
Teller was built with unit/integration/UI testing in mind. Here is how to use Teller in your tests:
RepositoryDataSource
or PagingRepositoryDataSource
implementationsYour implementations of RepositoryDataSource
or PagingRepositoryDataSource
should be no problem. RepositoryDataSource
and PagingRepositoryDataSource
are just protocols. You can unit test your implementation using dependency injection, for example, to test all of the functions of RepositoryDataSource
or PagingRepositoryDataSource
.
TellerRepository
or TellerPagingRepository
For your app's code that uses the Teller TellerRepository
or TellerPagingRepository
class, use the pre-built TellerRepositoryMock
or TellerPagingRepositoryMock
for your unit tests. Inject the mock into your class under test using dependency injection.
Here is an example XCTest for unit testing a class that depends on Teller's TellerRepository
(use the same concept below for working with TellerPagingRepository
except use TellerPagingRepositoryMock
).
import RxBlocking
import RxSwift
@testable import YourApp
import XCTest
class RepositoryViewModelTest: XCTestCase {
var viewModel: ReposViewModel!
var repository: RepositoryMock<ReposRepositoryDataSource>!
override func setUp() {
// Create an instance of `RepositoryMock`
repository = TellerRepositoryMock(dataSource: ReposRepositoryDataSource())
// Provide the repository mock to your code under test with dependency injection
viewModel = ReposViewModel(reposRepository: repository)
}
func test_observeRepos_givenReposRepositoryObserve_expectReceiveCacheFromReposRepository() {
// 1. Setup the mock
let given: CacheState<[Repo]> = DataState.testing.cache(requirements: ReposRepositoryDataSource.Requirements(username: "username"), lastTimeFetched: Date()) {
$0.cache([
Repo(id: 1, name: "repo-name")
])
}
repository.observeClosure = {
return Observable.just(given)
}
// 2. Run the code under test
let actual = try! repository.observe().toBlocking().first()
// 3. Assert your code under test is working
XCTAssertEqual(given, actual)
}
func test_setReposToObserve_givenUsername_expectSetRepositoryRequirements() {
let given = "random-username"
// Run your code under test
viewModel.setReposToObserve(username: given)
// Pull out the properties of the repository mock to see if your code under test works as expected
XCTAssertTrue(repository.requirementsCalled)
XCTAssertEqual(repository.requirementsCallsCount, 1)
let actual = repository.requirementsInvocations[0]!.username
XCTAssertEqual(given, actual)
}
}
Integration tests are a great way to make sure that many moving pieces of your code are working together correctly. Teller provides easy ways for you to write integration tests in your app that uses Teller.
The idea behind testing with Teller is to give Teller a pre-defined state to be in. Maybe you need to write an integration test for when the app starts up for the first time. Maybe you need to write a test where a cache already exists that Teller fetched. Let's get into how we do this in our tests.
setup()
test function:import XCTest
import Teller
class YourIntegrationTests: XCTestCase {
override func setUp() {
Teller.shared.clear()
}
}
import XCTest
import Teller
class YourIntegrationTests: XCTestCase {
private var dataSource: RepositoryDataSource<String, RepositoryRequirements, String>!
private var repository: TellerRepository<RepositoryDataSource<String, RepositoryRequirements, String>>!
override func setUp() {
dataSource = RepositoryDataSource()
repository = TellerRepository(dataSource: dataSource)
Teller.shared.clear()
}
func test_tellerNoCach() {
let requirements = RepositoryDataSource.Requirements(username: "")
_ = TellerRepository.testing.initState(repository: repository, requirements: requirements) {
$0.noCache()
}
// Teller is all setup! When your app's code uses the `repository`, it will behave just like the `TellerRepository` has never fetched a cache successfully before.
// Write the remainder of your integration test function here.
}
func test_tellerCacheEmpty() {
let requirements = RepositoryDataSource.Requirements(username: "")
_ = TellerRepository.testing.initState(repository: repository, requirements: requirements) {
$0.cacheEmpty() {
$0.cacheTooOld()
}
}
// Teller is all setup! When your app's code uses the `repository`, it will behave just like the `TellerRepository` has fetched a cache successfully, the cache is empty, and the cache is too old which means Teller will attempt to fetch a fresh cache the next time the `TellerRepository` runs.
// There are other options for `$0.cacheEmpty()` such as:
// $0.cacheEmpty() {
// $0.cacheNotTooOld()
// }
//
// $0.cacheEmpty() {
// $0.lastFetched(Date.yesterday)
// }
// Write the remainder of your integration test function here.
}
func test_tellerCacheNotEmpty() {
let requirements = RepositoryDataSource.Requirements(username: "")
let existingCache = "existing-cache"
_ = TellerRepository.testing.initState(repository: repository, requirements: requirements) {
$0.cache(existingCache) {
$0.cacheNotTooOld()
}
}
// *Note: If your `DataSource.saveCache()` function needs to be executed on a background thread, use `TellerRepository.testing.initStateAsync()` instead of `initState()` shown here. `initSync()` runs the `DataSource.saveCache()` on the thread that you call `initState()` on.*
// Teller is all setup! When your app's code uses the `repository`, it will behave just like the `TellerRepository` has fetched a cache successfully, the cache is not empty and contains "existing-cache", and the cache is not too old (the default behavior when a state is not given) which means Teller will not attempt to fetch a fresh cache the next time the `TellerRepository` runs.
// There are other options for `$0.cache(existingCache)`. They are the same options as $0.cacheEmpty() described above.
// Write the remainder of your integration test function here.
}
}
However, if you check out the directory: Example/Teller/
you will see a fully functional iOS app with code snippets you can use to learn about how to use Teller, learn best practices, and compile inside of XCode.
Documentation is coming shortly. This README is all of the documentation created thus far.
If you read the README and still have questions, please, create an issue with your question. I will respond with an answer and update the README docs to help others in the future.
Teller is a pretty simple CocoaPods XCode workspace. Follow the directions below for the optimal development experience.
$> bundle install
$> cd Example/; pod install; cd ..;
$> ./hooks/autohook.sh install # installs git hooks
Teller is open for pull requests. Check out the list of issues for tasks I am planning on working on. Check them out if you wish to contribute in that way.
Want to add features to Teller? Before you decide to take a bunch of time and add functionality to the library, please, [create an issue] (https://github.com/levibostian/Teller-iOS/issues/new) stating what you wish to add. This might save you some time in case your purpose does not fit well in the use cases of Teller.
This library is a powerful Repository. The Repository design pattern is commonly found in the MVVM and MVI patterns. A synonym of repository is bank. A bank teller is someone who manages your money at a bank and triggers transactions. So, since this library facilitates transactions, teller fits.