package com.kickstarter.libs; import android.support.annotation.NonNull; import android.util.Pair; import com.kickstarter.libs.rx.transformers.Transformers; import com.kickstarter.libs.utils.ListUtils; import com.kickstarter.services.ApiClientType; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.List; import rx.Observable; import rx.functions.Func1; import rx.functions.Func2; import rx.subjects.PublishSubject; /** * An object to facilitate loading pages of data from the API. * * @param <Data> The type of data returned from the array, e.g. `Project`, `Activity`, etc. * @param <Envelope> The type of envelope the API returns for a list of data, e.g. `DiscoverEnvelope`. * @param <Params> The type of params that {@link ApiClientType} can use to make a request. Many times this can just be `Void`. */ public final class ApiPaginator<Data, Envelope, Params> { private final @NonNull Observable<Void> nextPage; private final @NonNull Observable<Params> startOverWith; private final @NonNull Func1<Envelope, List<Data>> envelopeToListOfData; private final @NonNull Func1<Params, Observable<Envelope>> loadWithParams; private final @NonNull Func1<String, Observable<Envelope>> loadWithPaginationPath; private final @NonNull Func1<Envelope, String> envelopeToMoreUrl; private final @NonNull Func1<List<Data>, List<Data>> pageTransformation; private final boolean clearWhenStartingOver; private final @NonNull Func2<List<Data>, List<Data>, List<Data>> concater; private final boolean distinctUntilChanged; private final @NonNull PublishSubject<String> _morePath = PublishSubject.create(); private final @NonNull PublishSubject<Boolean> _isFetching = PublishSubject.create(); // Outputs public @NonNull Observable<List<Data>> paginatedData() { return paginatedData; } private final @NonNull Observable<List<Data>> paginatedData; public @NonNull Observable<Boolean> isFetching() { return isFetching; } private final @NonNull Observable<Boolean> isFetching = _isFetching; public @NonNull Observable<Integer> loadingPage() { return loadingPage; } private final @NonNull Observable<Integer> loadingPage; private ApiPaginator( final @NonNull Observable<Void> nextPage, final @NonNull Observable<Params> startOverWith, final @NonNull Func1<Envelope, List<Data>> envelopeToListOfData, final @NonNull Func1<Params, Observable<Envelope>> loadWithParams, final @NonNull Func1<String, Observable<Envelope>> loadWithPaginationPath, final @NonNull Func1<Envelope, String> envelopeToMoreUrl, final @NonNull Func1<List<Data>, List<Data>> pageTransformation, final boolean clearWhenStartingOver, final @NonNull Func2<List<Data>, List<Data>, List<Data>> concater, final boolean distinctUntilChanged ) { this.nextPage = nextPage; this.startOverWith = startOverWith; this.envelopeToListOfData = envelopeToListOfData; this.loadWithParams = loadWithParams; this.envelopeToMoreUrl = envelopeToMoreUrl; this.pageTransformation = pageTransformation; this.loadWithPaginationPath = loadWithPaginationPath; this.clearWhenStartingOver = clearWhenStartingOver; this.concater = concater; this.distinctUntilChanged = distinctUntilChanged; this.paginatedData = this.startOverWith.switchMap(this::dataWithPagination); this.loadingPage = this.startOverWith.switchMap(__ -> nextPage.scan(1, (accum, ___) -> accum + 1)); } public final static class Builder<Data, Envelope, Params> { private Observable<Void> nextPage; private Observable<Params> startOverWith; private Func1<Envelope, List<Data>> envelopeToListOfData; private Func1<Params, Observable<Envelope>> loadWithParams; private Func1<String, Observable<Envelope>> loadWithPaginationPath; private Func1<Envelope, String> envelopeToMoreUrl; private Func1<List<Data>, List<Data>> pageTransformation; private boolean clearWhenStartingOver; private Func2<List<Data>, List<Data>, List<Data>> concater = ListUtils::concat; private boolean distinctUntilChanged; /** * [Required] An observable that emits whenever a new page of data should be loaded. */ public @NonNull Builder<Data, Envelope, Params> nextPage(final @NonNull Observable<Void> nextPage) { this.nextPage = nextPage; return this; } /** * [Optional] An observable that emits when a fresh first page should be loaded. */ public @NonNull Builder<Data, Envelope, Params> startOverWith(final @NonNull Observable<Params> startOverWith) { this.startOverWith = startOverWith; return this; } /** * [Required] A function that takes an `Envelope` instance and returns the list of data embedded in it. */ public @NonNull Builder<Data, Envelope, Params> envelopeToListOfData(final @NonNull Func1<Envelope, List<Data>> envelopeToListOfData) { this.envelopeToListOfData = envelopeToListOfData; return this; } /** * [Required] A function to extract the more URL from an API response envelope. */ public @NonNull Builder<Data, Envelope, Params> envelopeToMoreUrl(final @NonNull Func1<Envelope, String> envelopeToMoreUrl) { this.envelopeToMoreUrl = envelopeToMoreUrl; return this; } /** * [Required] A function that makes an API request with a pagination URL. */ public @NonNull Builder<Data, Envelope, Params> loadWithPaginationPath(final @NonNull Func1<String, Observable<Envelope>> loadWithPaginationPath) { this.loadWithPaginationPath = loadWithPaginationPath; return this; } /** * [Required] A function that takes a `Params` and performs the associated network request * and returns an `Observable<Envelope>` */ public @NonNull Builder<Data, Envelope, Params> loadWithParams(final @NonNull Func1<Params, Observable<Envelope>> loadWithParams) { this.loadWithParams = loadWithParams; return this; } /** * [Optional] Function to transform every page of data that is loaded. */ public @NonNull Builder<Data, Envelope, Params> pageTransformation(final @NonNull Func1<List<Data>, List<Data>> pageTransformation) { this.pageTransformation = pageTransformation; return this; } /** * [Optional] Determines if the list of loaded data is cleared when starting over from the first page. */ public @NonNull Builder<Data, Envelope, Params> clearWhenStartingOver(final boolean clearWhenStartingOver) { this.clearWhenStartingOver = clearWhenStartingOver; return this; } /** * [Optional] Determines how two lists are concatenated together while paginating. A regular `ListUtils::concat` is probably * sufficient, but sometimes you may want `ListUtils::concatDistinct` */ public @NonNull Builder<Data, Envelope, Params> concater(final @NonNull Func2<List<Data>, List<Data>, List<Data>> concater) { this.concater = concater; return this; } /** * [Optional] Determines if the list of loaded data is should be distinct until changed. */ public @NonNull Builder<Data, Envelope, Params> distinctUntilChanged(final boolean distinctUntilChanged) { this.distinctUntilChanged = distinctUntilChanged; return this; } public @NonNull ApiPaginator<Data, Envelope, Params> build() throws RuntimeException { // Early error when required field is not set if (nextPage == null) { throw new RuntimeException("`nextPage` is required"); } if (envelopeToListOfData == null) { throw new RuntimeException("`envelopeToListOfData` is required"); } if (loadWithParams == null) { throw new RuntimeException("`loadWithParams` is required"); } if (loadWithPaginationPath == null) { throw new RuntimeException("`loadWithPaginationPath` is required"); } if (envelopeToMoreUrl == null) { throw new RuntimeException("`envelopeToMoreUrl` is required"); } // Default params for optional fields if (startOverWith == null) { startOverWith = Observable.just(null); } if (pageTransformation == null) { pageTransformation = x -> x; } if (concater == null) { concater = ListUtils::concat; } return new ApiPaginator<>(nextPage, startOverWith, envelopeToListOfData, loadWithParams, loadWithPaginationPath, envelopeToMoreUrl, pageTransformation, clearWhenStartingOver, concater, distinctUntilChanged); } } public @NonNull static <Data, Envelope, FirstPageParams> Builder<Data, Envelope, FirstPageParams> builder() { return new Builder<>(); } /** * Returns an observable that emits the accumulated list of paginated data each time a new page is loaded. */ private @NonNull Observable<List<Data>> dataWithPagination(final @NonNull Params firstPageParams) { final Observable<List<Data>> data = paramsAndMoreUrlWithPagination(firstPageParams) .concatMap(this::fetchData) .takeUntil(List::isEmpty); final Observable<List<Data>> paginatedData = clearWhenStartingOver ? data.scan(new ArrayList<>(), concater) : data.scan(concater); return distinctUntilChanged ? paginatedData.distinctUntilChanged() : paginatedData; } /** * Returns an observable that emits the params for the next page of data *or* the more URL for the next page. */ private @NonNull Observable<Pair<Params, String>> paramsAndMoreUrlWithPagination(final @NonNull Params firstPageParams) { return _morePath .map(path -> new Pair<Params, String>(null, path)) .compose(Transformers.takeWhen(nextPage)) .startWith(new Pair<>(firstPageParams, null)); } private @NonNull Observable<List<Data>> fetchData(final @NonNull Pair<Params, String> paginatingData) { return (paginatingData.second != null ? loadWithPaginationPath.call(paginatingData.second) : loadWithParams.call(paginatingData.first)) .retry(2) .compose(Transformers.neverError()) .doOnNext(this::keepMorePath) .map(envelopeToListOfData) .map(pageTransformation) .doOnSubscribe(() -> _isFetching.onNext(true)) .doAfterTerminate(() -> _isFetching.onNext(false)); } private void keepMorePath(final @NonNull Envelope envelope) { try { final URL url = new URL(envelopeToMoreUrl.call(envelope)); _morePath.onNext(pathAndQueryFromURL(url)); } catch (MalformedURLException ignored) {} } private @NonNull String pathAndQueryFromURL(final @NonNull URL url) { return url.getPath() + "?" + url.getQuery(); } }