Skip to content

Instantly share code, notes, and snippets.

@srnagar
Last active December 9, 2024 05:46
Show Gist options
  • Save srnagar/e9373153593920dcf1977c928c59e988 to your computer and use it in GitHub Desktop.
Save srnagar/e9373153593920dcf1977c928c59e988 to your computer and use it in GitHub Desktop.
Support pagination in unbranded libraries

Support pagination in unbranded libraries

TypeSpec recently added support for pagination that allows services to define APIs that return a paginated collection of results.

There are two types of pagination:

  • Client driven pagination
  • Server driven pagination

A service can choose to support either or both of these pagination types.

Client driven pagination

This type allows the client to control the pagination behavior. The client maintains the necessary state to request the next set of results from the server. TypeSpec supports configuring the following in a client request:

  • Page Size - number of items in a page
  • Page Index - the page number
  • Offset - number of items to skip

Server driven pagination

The server can return additional metadata along with the results to inform the client how to fetch the next set of results. The metadata can contain a continuation token or a set of links that can let the client fetch first, last, next or previous page.

Pagination in unbranded client libraries

Option 1: Create a common paging type in core

Option 1: Create a common paging type in core

This approach is similar to what we have in Azure. The base paging types will be defined in core with generics that allow library operations to define the generic types.

TypeSpec example

Let's use an example that supports all above scenarios

@list op listPets(@query @pageIndex page?: int32, @query @pageSize perPage?: int32): {
  @pageItems pets: Pet[];
  @nextLink next?: url;
  @prevLink prev?: url;
  @firstLink first?: url;
  @lastLink last?: url;
};


@list op listPetStores(String zipCode, String continuationToken): {
  @pageItems petStores: PetStore[];
  @continuationToken continuationToken: string;
};

ClientCore types

public class PagedResponse<T> implements Response<List<T>> {
    public List<T> getItems() {}
    public String getContinuationToken() {}
    public String getNextLink() {}
    public String getPreviousLink() {}
    public String getFirstLink() {}
    public String getLastLink() {}
}

public class PagedIterable<T> {
    public Iterable<T> iterable() {}
    public Iterable<PagedResponse<T>> iterableByPage() {}
    public Iterable<PagedResponse<T>> iterableByPage(PagingOptions options) {}

    public Stream<T> stream() {}
    public Stream<PagedResponse<T>> streamByPage() {}
    public Stream<PagedResponse<T>> streamByPage(PagingOptions options) {}
}

public class PagingOptions {
    private Long offset;
    private Long pageSize;
    private Long pageIndex;

    // TypeSpec only support string continuation tokens
    private String continuationToken;

    // public getters and setters
}

Client Library

public class PetClient {
    public PagedIterable<Pet> listPets();
    public PagedIterable<PetStore> listPetStores(String zipCode);
}

User code

PetClient client = instantiateClient();


// Listing pets
PagedIterable<Pet> petIterable = client.listPets();

// to iterate through all pets
petIterable.stream()
           .forEach(pet -> System.out.println(pet.getName()));

// to iterate through pages
petIterable.streamByPage()
           .flatMap(pagedResponse -> pagedResponse.getItems().stream())
           .forEach(pet -> System.out.println(pet.getName()));

// to iterate through pages starting from some offset
PagingOptions petPagingOptions = new PagingOptions()
                        .setPageIndex(5)
                        .setPageSize(10)
                        .setOffset(50);

petIterable.streamByPage(petPagingOptions)
    .peek(pagedResponse -> System.out.println(pagedResponse.getNextLink()))
    .flatMap(pagedResponse -> pagedResponse.getItems().stream())
    .forEach(pet -> System.out.println(pet.getName()));


// Listing pet stores

PagedIterable<PetStore> petStoreIterable = client.listPetStores("11235");

// to iterate through pages from a continuation token
PagingOptions petPagingOptions = new PagingOptions()
                        .setOffset(50) // this will not work as the operation doesn't support it
                        .setContinuationToken("xuw1dv");

petStoreIterable.streamByPage(petStorePagingOptions)
    .peek(pagedResponse -> System.out.println(pagedResponse.getContinuationToken()))
    .peek(pagedResponse -> System.out.println(pagedResponse.getNextLink())) // this will always be null as the op doesn't return these links
    .flatMap(pagedResponse -> pagedResponse.getItems().stream())
    .forEach(petStore -> System.out.println(petStore.getAddress()));

Pros

  • Consistent paging experience across libraries

Cons

  • The common paging type will have to support all of the above scenarios while only a subset of the scenarios are supported by the service

Option 2: Create a paging type per library

Option 2: Create a paging type per library

This approach will create a paging type per library and will tailor the APIs to support only the paging scenarios the service supports.

TypeSpec

@list op listPets(@query @pageSize perPage?: int32): {
  @pageItems pets: Pet[];
  @nextLink next?: url;
};


@list op listPetStores(String zipCode, String continuationToken): {
  @pageItems petStores: PetStore[];
  @continuationToken continuationToken: string;
};

Client Library

public class PetServicePagedResponse<T> implements Response<List<T>> {
    public List<T> getItems() {}
    public String getContinuationToken() {}
    public String getNextLink();
}

public class PetServicePagingOptions {
    private Long pageSize;

    // TypeSpec only support string continuation tokens
    private String continuationToken;

    // public getters and setters
}

public class PetServicePagedIterable<T> {
    public Iterable<T> iterable() {}
    public Iterable<PetServicePagedResponse<T>> iterableByPage() {}
    public Iterable<PetServicePagedResponse<T>> iterableByPage(PetServicePagingOptions options) {}

    public Stream<T> stream() {}
    public Stream<PetServicePagedResponse<T>> streamByPage() {}
    public Stream<PetServicePagedResponse<T>> streamByPage(PetServicePagingOptions options) {}
}

public class PetClient {
    public PetServicePagedIterable<Pet> listPets();
    public PetServicePagedIterable<PetStore> listPetStores(String zipCode);
}

User code

PetClient client = instantiateClient();


// Listing pets
PetServicePagedIterable<Pet> petIterable = client.listPets();

// to iterate through all pets
petIterable.stream()
    .forEach(pet -> System.out.println(pet.getName()));

// to iterate through pages
petIterable.streamByPage()
    .peek(pagedResponse -> System.out.println(pagedResponse.getContinuationToken()) // this will always be null
    .peek(pagedResponse -> System.out.println(pagedResponse.getNextLink()))
    .flatMap(pagedResponse -> pagedResponse.getItems().stream())
    .forEach(pet -> System.out.println(pet.getName()));

// to iterate through pages starting from some offset
PagingOptions petPagingOptions = new PagingOptions()
                        .setPageIndex(5)
                        .setPageSize(10)
                        .setOffset(50);

petIterable.streamByPage(petPagingOptions)
    .peek(pagedResponse -> System.out.println(pagedResponse.getNextLink()))
    .flatMap(pagedResponse -> pagedResponse.getItems().stream())
    .forEach(pet -> System.out.println(pet.getName()));

// Listing pet stores
PetServicePagedIterable<PetStore> petStoreIterable = client.listPetStores("11235");

// to iterate through pages from a continuation token
petStoreIterable.streamByPage("xuw1dv")
    .peek(pagedResponse -> System.out.println(pagedResponse.getContinuationToken()))
    .peek(pagedResponse -> System.out.println(pagedResponse.getNextLink())) // will always be null
    .flatMap(pagedResponse -> pagedResponse.getItems().stream())
    .forEach(petStore -> System.out.println(petStore.getAddress()));

Pros

  • Provides paging APIs that the service supports
  • Allows adding any additional information in the PageResponse type that is specific to the service

Cons

  • Requires a new paging type to be created for every library that supports pagination. Also, the paging type defined at the library will support all scenarios the service supports that may or may not be applicable to a specific operation. A service is generally expected to have same paging scenarios supported for all operations but there could be reasons for different operations to have different capabilities.

Option 3: Create a paging type per operation

Option 3: Create a paging type per operation

This is the most granular level of supporting pagination. Every operation defines its own paging type and will contain APIs specific to that operation.

@list op listPets(@query @pageIndex page?: int32, @query @pageSize perPage?: int32): {
  @pageItems pets: Pet[];
  @nextLink next?: url;
  @prevLink prev?: url;
  @firstLink first?: url;
  @lastLink last?: url;
};


@list op listPetStores(String zipCode, String continuationToken): {
  @pageItems petStores: PetStore[];
  @continuationToken continuationToken: string;
};

Client Library

public class PetPagedResponse implements Response<List<Pet>> {
    public List<Pet> getPets() {}
    public String getNextLink() {}
    public String getPreviousLink() {}
    public String getFirstLink() {}
    public String getLastLink() {}
}

public class PetsPagingOptions {
    private Long offset;
    private Long pageSize;
    private Long pageIndex;

    // public getters and setters
}

public class PetsIterable {
    public Iterable<Pet> iterable() {}
    public Iterable<PetPagedResponse> iterableByPage() {}
    public Iterable<PetPagedResponse> iterableByPage(PetsPagingOptions options) {}

    public Stream<Pet> stream() {}
    public Stream<PetPagedResponse> streamByPage() {}
    public Stream<PetPagedResponse> streamByPage(PetsPagingOptions options) {}
}


public class PetStorePagedResponse implements Response<List<PetStore>> {
    public List<PetStore> getPetStores() {}
    public String getContinuationToken() {}
}

public class PetStoresIterable {
    public Iterable<PetStores> iterable() {}
    public Iterable<PetStoresPagedResponse> iterableByPage() {}
    public Iterable<PetStoresPagedResponse> iterableByPage(String continuationToken) {}

    public Stream<PetStores> stream() {}
    public Stream<PetStoresPagedResponse> streamByPage() {}
    public Stream<PetStoresPagedResponse> streamByPage(String continuationToken) {}
}

public class PetClient {
    public PetsIterable listPets();
    public PetStoresIterable listPetStores(String zipCode);
}

User code

PetClient client = instantiateClient();


// Listing pets

PetsIterable petIterable = client.listPets();

// to iterate through all pets
petIterable.stream()
           .forEach(pet -> System.out.println(pet.getName()));

// to iterate through pages
petIterable.streamByPage()
           .flatMap(pagedResponse -> pagedResponse.getItems().stream())
           .forEach(pet -> System.out.println(pet.getName()));

// to iterate through pages starting from some offset
PagingOptions petPagingOptions = new PagingOptions()
                        .setPageIndex(5)
                        .setPageSize(10)
                        .setOffset(50);

petIterable.streamByPage(petPagingOptions)
    .flatMap(pagedResponse -> pagedResponse.getItems().stream())
    .forEach(pet -> System.out.println(pet.getName()));

// Listing pet stores
PetStoresIterable petStoreIterable = client.listPetStores("11235");

// to iterate through pages from a continuation token
petStoreIterable.streamByPage("xuw1dv")
    .peek(pagedResponse -> System.out.println(pagedResponse.getContinuationToken()))
    .flatMap(pagedResponse -> pagedResponse.getItems().stream())
    .forEach(petStore -> System.out.println(petStore.getAddress()));

Pros

  • Very granular and provides users with APIs that are supported by that particular operation.
  • Allows adding any additional information in the PageResponse type that is specific to the operation

Cons

  • Proliferation of paging types for each library and each paging operation.
@samvaity
Copy link

samvaity commented Dec 3, 2024

I seem to prefer option 2. As it offers a hybrid approach with common base types for extensibility is possible. Option 2 also helps with keeping up with particular service needs and still providing consistency to users. Also, usage wise option 2 is much similar to option 1 with lesser overwhelming APIs (only supported APIs by the service) as opposed to option 1.
For option 3, becomes too granular, might make it confusing for users with too many variations.

@billwert
Copy link

billwert commented Dec 4, 2024

Couple questions:

  1. I assume all the instances in #2 of PagedResponse should have been PetServicePagedResponse?
  2. What's the API look like for actually using the next/prev/first/last links? Can we emit corresponding methods to the client, instead of surfacing the URL to the user? (Or in addition to, if we think there are scenarios where the user needs the real link.)

I'd like #1 because I think there's value in users being able to learn how something works conceptually and apply that everywhere, so having common types is nice. However I think generating methods that would not work in some circumstance (client only, etc) is a non-starter, so #2 seems correct.

How will service implementors decide what to support? Do we think they will commonly support both client and server, or do we think they will choose one or the other?

I also wonder why a service might support both @offset and @pageIndex. I assume our client would just pass these through to the service and let it do whatever validation it wants to know you gave it a reasonable request..

@weidongxu-microsoft
Copy link

weidongxu-microsoft commented Dec 5, 2024

For "Option 1: Create a common paging type in core" PagingOptions. Backend may not support all of them, so we need to avoid user think they can control the "pageIndex" but backend does not support that. -- It kind of apply to "Option 2" as well, though it should be more consistent for a single backend.
PS: it seems to me that pageIndex and offset are probably be mutually exclusive (not necessarily so, but likely).

For "Option 2" and "Option 3", the typical problem is again the naming. Say emitter create a PetServicePagingOptions (e.g. based on service name PetService), while typespec source have another model with same name.
(PS: for unbranded, we may not even have a concept of "service"/"service name" -- we don't have a e.g. "##ServiceVersion" class. There may be just a collection of "client"s)

@g2vinay
Copy link

g2vinay commented Dec 5, 2024

Hi Srikanta,
Thank you for this detailed write up.

Summary

Going over this, tried to summarize it here across key criterias, to make it easy to understand.

Criteria Option 1: Common Type Option 2: Per Library Option 3: Per Operation
Consistency High: Uniform API across libraries. Medium: Consistent within libraries, but varies across them. Low: Fragmented experience; APIs vary even within libraries.
Granularity Low: Generalized logic reduces specificity. Medium: Library-specific but limited granularity for individual operations. High: Operation-specific types allow precise alignment with service needs.
Customization Low: Limited, as it must cater to all use cases. High: Tailored to library needs but constrained by shared logic. Very High: Fully tailored to each operation’s unique requirements.
Complexity Medium: Simple overall but can become overloaded with diverse use cases. Medium: Similar to Option 1, but complexity is distributed. High: Significant complexity from maintaining numerous operation-specific types.
Reusability High: Shared type eliminates duplication across libraries. Medium: Reusable within a library but not across libraries. Low: Tightly coupled to individual operations, limiting reuse.
Maintenance Effort Medium: Centralized, but changes can impact all libraries. Medium: Localized to libraries, reducing cross-library risks. High: High effort due to proliferation of operation-specific types.

Thoughts

  • I like the theme of Option 1 approach in general but with Paging Options offering only the general types supported across services.
    The downstream libraries can extend the classes to make it adapt to the service needs as needed.

  • The idea behind Option 2 is great as well, but I'm worried the UX can become inconsistent across libraries and making it the default approach seems aggressive as variation across services won't be that common I'm assuming (correct me, if I'm wrong).
    Option 2, can work as well, if we make the Iterable classes implement well defined interfaces from Core, to keep the UX consistent, the naming will get wonky but, that's fine.

  • Option 3, seems overkill at first, not sure, if that level of granularity is needed by default, In exceptional cases, the libraries can implement that at the library level.

@joshfree
Copy link

joshfree commented Dec 5, 2024

My notes

  • Option 1 is closest to existing Azure SDK experience where there are generic base types in core that all SDKs use. This is a very consistent experience. The downside is that the customer can write code that will fail when it runs (as each service supports a subset of the public API)
  • Option 3 solves the problem of having code exposed which the service won't support. The downside is type-explosion as public types exist for each unique pageable type
  • Option 2 is a middle-ground solution which reduces but does not eliminate the number of public APIs that are non-sensical for a service. At the cost of having lowest-common-denominator types per SDK (rather than lowest-common-denominator types for all SDKs).

Question: How much API proliferation does Option-3 truly cause? (EDIT: Storage as an example would add 15+ new types alone)

@jairmyree
Copy link

My opinion:

  • Option 1: I like the consistency it creates across libraries but seems potentially problematic behavior if users assume paging features translate across services (especially when the services don't clearly define what the don't support / expected behavior in failure
  • Option 2: I like this one a bit more because I think it's more of a fair assumption to say that services would keep consistent paging across operations. Consistency could be maintained across libraries as much as the libraries are consistent.
  • Option 3: I think we'd most likely come to regret this option in the future.

@weidongxu-microsoft
Copy link

weidongxu-microsoft commented Dec 9, 2024

If we put PagingOptions to API (instead of argument to streamByPage)

PagedIterable<Pet> petIterable = client.listPets(petPagingOptions);

There could be another alternative

  1. have page related classes PagedResponse and PagedIterable in clientcore
  2. generate the exact subclass of <service>PagingOptions in SDK

Downside: As design perspective, PagingOptions does appear more appropriate for streamByPage.


Another uncertainty is, that if service only support client-side pagination (no nextLink in response), should SDK still support pagination via PagedIterable? (e.g. SDK automatically update next offset from current offset and response size, and send next REST call when user iterate the pages; stops when the REST call returns no more items).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment