Repository: TBD54566975/ssi-service
Pull request #537 (2023-06-12 21:53:33)

Support pagination for submissions
# Overview
This is the first of many PRs that will address https://github.com/TBD54566975/ssi-service/issues/349. It focuses on supporting pagination for submissions, by building off of #518 .

# Description
This follows the same pattern as the existing one. Shared functionality was refactored into separate packages. 

# How Has This Been Tested?
Unit tests were implemented.


codecov-commenter: ## [Codecov](https://app.codecov.io/gh/TBD54566975/ssi-service/pull/537?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975) Report
> Merging [#537](https://app.codecov.io/gh/TBD54566975/ssi-service/pull/537?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975) (17fd633) into [main](https://app.codecov.io/gh/TBD54566975/ssi-service/commit/32f3ad84e5cd0dade6c7f9ffe340487ba8eda4c8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975) (32f3ad8) will **increase** coverage by `0.14%`.
> The diff coverage is `10.71%`.

```diff
@@            Coverage Diff             @@
##             main     #537      +/-   ##
==========================================
+ Coverage   19.94%   20.09%   +0.14%     
==========================================
  Files          49       49              
  Lines        6183     6137      -46     
==========================================
  Hits         1233     1233              
+ Misses       4720     4676      -44     
+ Partials      230      228       -2     
```


| [Impacted Files](https://app.codecov.io/gh/TBD54566975/ssi-service/pull/537?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975) | Coverage Δ | |
|---|---|---|
| [pkg/server/router/did.go](https://app.codecov.io/gh/TBD54566975/ssi-service/pull/537?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975#diff-cGtnL3NlcnZlci9yb3V0ZXIvZGlkLmdv) | `4.37% <0.00%> (+0.80%)` | :arrow_up: |
| [pkg/server/router/presentation.go](https://app.codecov.io/gh/TBD54566975/ssi-service/pull/537?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975#diff-cGtnL3NlcnZlci9yb3V0ZXIvcHJlc2VudGF0aW9uLmdv) | `2.54% <0.00%> (-0.10%)` | :arrow_down: |
| [pkg/service/did/handler.go](https://app.codecov.io/gh/TBD54566975/ssi-service/pull/537?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975#diff-cGtnL3NlcnZpY2UvZGlkL2hhbmRsZXIuZ28=) | `0.00% <ø> (ø)` | |
| [pkg/service/did/key.go](https://app.codecov.io/gh/TBD54566975/ssi-service/pull/537?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975#diff-cGtnL3NlcnZpY2UvZGlkL2tleS5nbw==) | `0.00% <0.00%> (ø)` | |
| [pkg/service/did/service.go](https://app.codecov.io/gh/TBD54566975/ssi-service/pull/537?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975#diff-cGtnL3NlcnZpY2UvZGlkL3NlcnZpY2UuZ28=) | `0.00% <0.00%> (ø)` | |
| [pkg/service/did/web.go](https://app.codecov.io/gh/TBD54566975/ssi-service/pull/537?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975#diff-cGtnL3NlcnZpY2UvZGlkL3dlYi5nbw==) | `0.00% <0.00%> (ø)` | |
| [pkg/service/did/ion.go](https://app.codecov.io/gh/TBD54566975/ssi-service/pull/537?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975#diff-cGtnL3NlcnZpY2UvZGlkL2lvbi5nbw==) | `61.88% <100.00%> (ø)` | |
| [pkg/service/did/storage.go](https://app.codecov.io/gh/TBD54566975/ssi-service/pull/537?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975#diff-cGtnL3NlcnZpY2UvZGlkL3N0b3JhZ2UuZ28=) | `57.85% <100.00%> (+2.37%)` | :arrow_up: |



Patch:
diff --git a/doc/swagger.yaml b/doc/swagger.yaml
index eaca9a9..fc467e6 100644
--- a/doc/swagger.yaml
+++ b/doc/swagger.yaml
@@ -1611,6 +1611,10 @@ definitions:
     type: object
   pkg_server_router.ListSubmissionResponse:
     properties:
+      nextPageToken:
+        description: Pagination token to retrieve the next page of results. If the
+          value is "", it means no further results for the request.
+        type: string
       submissions:
         items:
           $ref: '#/definitions/github_com_tbd54566975_ssi-service_pkg_service_presentation_model.Submission'
@@ -3417,6 +3421,16 @@ paths:
         in: query
         name: filter
         type: string
+      - description: Hint to the server of the maximum elements to return. More may
+          be returned. When not set, the server will return all elements.
+        in: query
+        name: pageSize
+        type: number
+      - description: Used to indicate to the server to return a specific page of the
+          list results. Must match a previous requests' `nextPageToken`.
+        in: query
+        name: pageToken
+        type: string
       produces:
       - application/json
       responses:
diff --git a/pkg/server/pagination/pagination.go b/pkg/server/pagination/pagination.go
new file mode 100644
index 0000000..7e819f6
--- /dev/null
+++ b/pkg/server/pagination/pagination.go
@@ -0,0 +1,136 @@
+package pagination
+
+import (
+	"encoding/base64"
+	"fmt"
+	"net/http"
+	"net/url"
+	"reflect"
+	"strconv"
+
+	"github.com/gin-gonic/gin"
+	"github.com/goccy/go-json"
+	"github.com/sirupsen/logrus"
+	"github.com/tbd54566975/ssi-service/pkg/server/framework"
+	"github.com/tbd54566975/ssi-service/pkg/service/common"
+)
+
+type PageToken struct {
+	EncodedQuery  string
+	NextPageToken string
+}
+
+const (
+	PageSizeParam  = "pageSize"
+	PageTokenParam = "pageToken"
+)
+
+// ParsePaginationParams reads the PageSizeParam and PageTokenParam from the URL parameters and populates the passed in
+// pageRequest. The value encoded in PageTokenParam is assumed to be the base64url encoding of a PageToken. It is an
+// error for the query params to be different from the query params encoded in the PageToken. Any error during the
+// execution is responded to using the passed in gin.Context. The return value corresponds to whether there was an
+// error within the function.
+func ParsePaginationParams(c *gin.Context, pageRequest *PageRequest) bool {
+	pageSizeStr := framework.GetParam(c, PageSizeParam)
+
+	if pageSizeStr != nil {
+		pageSize, err := strconv.Atoi(*pageSizeStr)
+		if err != nil {
+			errMsg := fmt.Sprintf("list DIDs by method request encountered a problem with the %q query param", PageSizeParam)
+			framework.LoggingRespondErrMsg(c, errMsg, http.StatusBadRequest)
+			return true
+		}
+		if pageSize <= 0 {
+			errMsg := fmt.Sprintf("'%s' must be greater than 0", PageSizeParam)
+			framework.LoggingRespondErrMsg(c, errMsg, http.StatusBadRequest)
+			return true
+		}
+		pageRequest.PageSize = &pageSize
+	}
+
+	queryPageToken := framework.GetParam(c, PageTokenParam)
+	if queryPageToken != nil {
+		errMsg := "token value cannot be decoded"
+		tokenData, err := base64.RawURLEncoding.DecodeString(*queryPageToken)
+		if err != nil {
+			framework.LoggingRespondErrMsg(c, errMsg, http.StatusBadRequest)
+			return true
+		}
+		var pageToken PageToken
+		if err := json.Unmarshal(tokenData, &pageToken); err != nil {
+			framework.LoggingRespondErrMsg(c, errMsg, http.StatusBadRequest)
+			return true
+		}
+		pageTokenValues, err := url.ParseQuery(pageToken.EncodedQuery)
+		if err != nil {
+			framework.LoggingRespondErrMsg(c, errMsg, http.StatusBadRequest)
+			return true
+		}
+
+		query := pageTokenQuery(c)
+		if !reflect.DeepEqual(pageTokenValues, query) {
+			logrus.Warnf("expected query from token to be equal to query from request. token: %v\nrequest%v", pageTokenValues, query)
+			framework.LoggingRespondErrMsg(c, "page token must be for the same query", http.StatusBadRequest)
+			return true
+		}
+		pageRequest.PageToken = &pageToken.NextPageToken
+	}
+	return false
+}
+
+func pageTokenQuery(c *gin.Context) url.Values {
+	query := c.Request.URL.Query()
+	delete(query, PageTokenParam)
+	delete(query, PageSizeParam)
+	return query
+}
+
+// MaybeSetNextPageToken encodes the serviceNextPageToken and the URL query params into a base64url string. The encoded
+// string is assigned to what respNextPageToken is pointing to. respNextPageToken cannot be nil. Any error during the
+// execution is responded to using the passed in gin.Context. The return value corresponds to whether there was an error
+// within the function.
+func MaybeSetNextPageToken(c *gin.Context, serviceNextPageToken string, respNextPageToken *string) bool {
+	if serviceNextPageToken != "" {
+		tokenQuery := pageTokenQuery(c)
+		pageToken := PageToken{
+			EncodedQuery:  tokenQuery.Encode(),
+			NextPageToken: serviceNextPageToken,
+		}
+		nextPageTokenData, err := json.Marshal(pageToken)
+		if err != nil {
+			framework.LoggingRespondErrWithMsg(c, err, "marshalling page token", http.StatusInternalServerError)
+			return true
+		}
+		encodedToken := base64.RawURLEncoding.EncodeToString(nextPageTokenData)
+		*respNextPageToken = encodedToken
+	}
+	return false
+}
+
+// PageRequest contains the parameters sent in the request.
+type PageRequest struct {
+	// PageSize is the value associated with PageSizeParam. A nil value means it was not present in the query. When the parameter
+	// is absent, all items in the collection are included in the response.
+	PageSize *int `json:"pageSize,omitempty"`
+
+	// PageToken is the value associated with PageTokenParam. A nil value means it was not present in the query.
+	PageToken *string `json:"pageToken,omitempty"`
+}
+
+func (r *PageRequest) ToServicePage() *common.Page {
+	const allPages = -1
+	page := common.Page{
+		Size: allPages,
+	}
+	if r == nil {
+		return &page
+	}
+
+	if r.PageSize != nil {
+		page.Size = *r.PageSize
+	}
+	if r.PageToken != nil {
+		page.Token = *r.PageToken
+	}
+	return &page
+}
diff --git a/pkg/server/router/did.go b/pkg/server/router/did.go
index 7256b8e..039c3ca 100644
--- a/pkg/server/router/did.go
+++ b/pkg/server/router/did.go
@@ -1,11 +1,8 @@
 package router
 
 import (
-	"encoding/base64"
 	"fmt"
 	"net/http"
-	"net/url"
-	"reflect"
 	"strconv"
 
 	"github.com/TBD54566975/ssi-sdk/crypto"
@@ -14,7 +11,7 @@ import (
 	"github.com/gin-gonic/gin"
 	"github.com/goccy/go-json"
 	"github.com/pkg/errors"
-	"github.com/sirupsen/logrus"
+	"github.com/tbd54566975/ssi-service/pkg/server/pagination"
 
 	"github.com/tbd54566975/ssi-service/internal/util"
 	"github.com/tbd54566975/ssi-service/pkg/server/framework"
@@ -23,11 +20,9 @@ import (
 )
 
 const (
-	MethodParam    = "method"
-	IDParam        = "id"
-	DeletedParam   = "deleted"
-	PageSizeParam  = "pageSize"
-	PageTokenParam = "pageToken"
+	MethodParam  = "method"
+	IDParam      = "id"
+	DeletedParam = "deleted"
 )
 
 // DIDRouter represents the dependencies required to instantiate a DID-HTTP service
@@ -240,11 +235,6 @@ type GetDIDsRequest struct {
 	Filter string `json:"filter,omitempty"`
 }
 
-type PageToken struct {
-	EncodedQuery  string
-	NextPageToken string
-}
-
 // ListDIDsByMethod godoc
 //
 //	@Summary		List DIDs
@@ -281,47 +271,15 @@ func (dr DIDRouter) ListDIDsByMethod(c *gin.Context) {
 	}
 	// TODO(gabe) check if the method is supported, to tell whether this is a bad req or internal error
 	// TODO(gabe) differentiate between internal errors and not found DIDs
-	getDIDsRequest := did.ListDIDsRequest{Method: didsdk.Method(*method), Deleted: getIsDeleted}
-
-	pageSizeStr := framework.GetParam(c, PageSizeParam)
-
-	if pageSizeStr != nil {
-		pageSize, err := strconv.Atoi(*pageSizeStr)
-		if err != nil {
-			errMsg := fmt.Sprintf("list DIDs by method request encountered a problem with the %q query param", PageSizeParam)
-			framework.LoggingRespondErrMsg(c, errMsg, http.StatusBadRequest)
-			return
-		}
-		getDIDsRequest.PageSize = &pageSize
+	getDIDsRequest := did.ListDIDsRequest{
+		Method:  didsdk.Method(*method),
+		Deleted: getIsDeleted,
 	}
-
-	queryPageToken := framework.GetParam(c, PageTokenParam)
-	if queryPageToken != nil {
-		errMsg := "token value cannot be decoded"
-		tokenData, err := base64.RawURLEncoding.DecodeString(*queryPageToken)
-		if err != nil {
-			framework.LoggingRespondErrMsg(c, errMsg, http.StatusBadRequest)
-			return
-		}
-		var pageToken PageToken
-		if err := json.Unmarshal(tokenData, &pageToken); err != nil {
-			framework.LoggingRespondErrMsg(c, errMsg, http.StatusBadRequest)
-			return
-		}
-		pageTokenValues, err := url.ParseQuery(pageToken.EncodedQuery)
-		if err != nil {
-			framework.LoggingRespondErrMsg(c, errMsg, http.StatusBadRequest)
-			return
-		}
-
-		query := pageTokenQuery(c)
-		if !reflect.DeepEqual(pageTokenValues, query) {
-			logrus.Warnf("expected query from token to be equal to query from request. token: %v\nrequest%v", pageTokenValues, query)
-			framework.LoggingRespondErrMsg(c, "page token must be for the same query", http.StatusBadRequest)
-			return
-		}
-		getDIDsRequest.PageToken = &pageToken.NextPageToken
+	var pageRequest pagination.PageRequest
+	if pagination.ParsePaginationParams(c, &pageRequest) {
+		return
 	}
+	getDIDsRequest.PageRequest = pageRequest.ToServicePage()
 
 	listResp, err := dr.service.ListDIDsByMethod(c, getDIDsRequest)
 	if err != nil {
@@ -333,29 +291,12 @@ func (dr DIDRouter) ListDIDsByMethod(c *gin.Context) {
 	resp := ListDIDsByMethodResponse{
 		DIDs: listResp.DIDs,
 	}
-	if listResp.NextPageToken != "" {
-		tokenQuery := pageTokenQuery(c)
-		pageToken := PageToken{
-			EncodedQuery:  tokenQuery.Encode(),
-			NextPageToken: listResp.NextPageToken,
-		}
-		nextPageTokenData, err := json.Marshal(pageToken)
-		if err != nil {
-			framework.LoggingRespondErrWithMsg(c, err, "marshalling page token", http.StatusInternalServerError)
-			return
-		}
-		resp.NextPageToken = base64.RawURLEncoding.EncodeToString(nextPageTokenData)
+	if pagination.MaybeSetNextPageToken(c, listResp.NextPageToken, &resp.NextPageToken) {
+		return
 	}
 	framework.Respond(c, resp, http.StatusOK)
 }
 
-func pageTokenQuery(c *gin.Context) url.Values {
-	query := c.Request.URL.Query()
-	delete(query, PageTokenParam)
-	delete(query, PageSizeParam)
-	return query
-}
-
 type ResolveDIDResponse struct {
 	ResolutionMetadata  *resolution.Metadata         `json:"didResolutionMetadata,omitempty"`
 	DIDDocument         *didsdk.Document             `json:"didDocument"`
diff --git a/pkg/server/router/presentation.go b/pkg/server/router/presentation.go
index ab6a0d5..07ea616 100644
--- a/pkg/server/router/presentation.go
+++ b/pkg/server/router/presentation.go
@@ -10,6 +10,7 @@ import (
 	"github.com/gin-gonic/gin"
 	"github.com/goccy/go-json"
 	"github.com/pkg/errors"
+	"github.com/tbd54566975/ssi-service/pkg/server/pagination"
 	"go.einride.tech/aip/filtering"
 
 	credint "github.com/tbd54566975/ssi-service/internal/credential"
@@ -341,6 +342,9 @@ func (l listSubmissionRequest) GetFilter() string {
 
 type ListSubmissionResponse struct {
 	Submissions []model.Submission `json:"submissions,omitempty"`
+
+	// Pagination token to retrieve the next page of results. If the value is "", it means no further results for the request.
+	NextPageToken string `json:"nextPageToken"`
 }
 
 // ListSubmissions godoc
@@ -350,10 +354,12 @@ type ListSubmissionResponse struct {
 //	@Tags			PresentationSubmissionAPI
 //	@Accept			json
 //	@Produce		json
-//	@Param			filter	query		string	false	"A standard filter expression conforming to https://google.aip.dev/160. For example: `?filter=status="pending"`"
-//	@Success		200		{object}	ListSubmissionResponse
-//	@Failure		400		{string}	string	"Bad request"
-//	@Failure		500		{string}	string	"Internal server error"
+//	@Param			filter		query		string	false	"A standard filter expression conforming to https://google.aip.dev/160. For example: `?filter=status="pending"`"
+//	@Param			pageSize	query		number	false	"Hint to the server of the maximum elements to return. More may be returned. When not set, the server will return all elements."
+//	@Param			pageToken	query		string	false	"Used to indicate to the server to return a specific page of the list results. Must match a previous requests' `nextPageToken`."
+//	@Success		200			{object}	ListSubmissionResponse
+//	@Failure		400			{string}	string	"Bad request"
+//	@Failure		500			{string}	string	"Internal server error"
 //	@Router			/v1/presentations/submissions [get]
 func (pr PresentationRouter) ListSubmissions(c *gin.Context) {
 	filterParam := framework.GetQueryValue(c, FilterParam)
@@ -397,13 +403,25 @@ func (pr PresentationRouter) ListSubmissions(c *gin.Context) {
 		return
 	}
 
-	resp, err := pr.service.ListSubmissions(c, model.ListSubmissionRequest{Filter: filter})
+	var pageRequest pagination.PageRequest
+	if pagination.ParsePaginationParams(c, &pageRequest) {
+		return
+	}
+
+	listResp, err := pr.service.ListSubmissions(c, model.ListSubmissionRequest{
+		Filter:      filter,
+		PageRequest: &pageRequest,
+	})
 	if err != nil {
 		errMsg := "failed listing submissions"
 		framework.LoggingRespondErrWithMsg(c, err, errMsg, http.StatusInternalServerError)
 		return
 	}
-	framework.Respond(c, ListSubmissionResponse{Submissions: resp.Submissions}, http.StatusOK)
+	resp := ListSubmissionResponse{Submissions: listResp.Submissions}
+	if pagination.MaybeSetNextPageToken(c, listResp.NextPageToken, &resp.NextPageToken) {
+		return
+	}
+	framework.Respond(c, resp, http.StatusOK)
 }
 
 type ReviewSubmissionRequest struct {
diff --git a/pkg/service/common/pagination.go b/pkg/service/common/pagination.go
new file mode 100644
index 0000000..0fac8a2
--- /dev/null
+++ b/pkg/service/common/pagination.go
@@ -0,0 +1,21 @@
+package common
+
+type Page struct {
+	// A storage dependent way of communicating the page to be retrieved. Empty string means the first page.
+	Token string
+
+	// A value of -1 means retrieval of all pages.
+	Size int
+}
+
+func (page *Page) ToStorageArgs() (string, int) {
+	token := ""
+	if page != nil {
+		token = page.Token
+	}
+	size := -1
+	if page != nil {
+		size = page.Size
+	}
+	return token, size
+}
diff --git a/pkg/service/did/handler.go b/pkg/service/did/handler.go
index da2f775..e37499a 100644
--- a/pkg/service/did/handler.go
+++ b/pkg/service/did/handler.go
@@ -7,6 +7,7 @@ import (
 	"github.com/TBD54566975/ssi-sdk/did/resolution"
 	"github.com/TBD54566975/ssi-sdk/util"
 	"github.com/pkg/errors"
+	"github.com/tbd54566975/ssi-service/pkg/service/common"
 )
 
 // MethodHandler describes the functionality of *all* possible DID service, regardless of method
@@ -24,7 +25,7 @@ type MethodHandler interface {
 	GetDID(ctx context.Context, request GetDIDRequest) (*GetDIDResponse, error)
 
 	// ListDIDs returns all non-deleted DIDs for the given page. When page is nil, all non-deleted DIDs will be returned.
-	ListDIDs(ctx context.Context, page *Page) (*ListDIDsResponse, error)
+	ListDIDs(ctx context.Context, page *common.Page) (*ListDIDsResponse, error)
 
 	// ListDeletedDIDs returns all soft-deleted DIDs.
 	ListDeletedDIDs(ctx context.Context) (*ListDIDsResponse, error)
diff --git a/pkg/service/did/ion.go b/pkg/service/did/ion.go
index b1392c8..cb83970 100644
--- a/pkg/service/did/ion.go
+++ b/pkg/service/did/ion.go
@@ -14,6 +14,7 @@ import (
 	"github.com/mr-tron/base58"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
+	"github.com/tbd54566975/ssi-service/pkg/service/common"
 
 	"github.com/tbd54566975/ssi-service/pkg/service/keystore"
 )
@@ -250,7 +251,7 @@ func (h *ionHandler) GetDID(ctx context.Context, request GetDIDRequest) (*GetDID
 }
 
 // ListDIDs returns all DIDs we have in storage for ION, it is not feasible to get all DIDs from the network
-func (h *ionHandler) ListDIDs(ctx context.Context, page *Page) (*ListDIDsResponse, error) {
+func (h *ionHandler) ListDIDs(ctx context.Context, page *common.Page) (*ListDIDsResponse, error) {
 	gotDIDs, err := h.storage.ListDIDsPage(ctx, did.IONMethod.String(), page, new(ionStoredDID))
 	if err != nil {
 		return nil, errors.Wrap(err, "error getting did:ion DIDs")
diff --git a/pkg/service/did/key.go b/pkg/service/did/key.go
index 9952b1e..167165a 100644
--- a/pkg/service/did/key.go
+++ b/pkg/service/did/key.go
@@ -10,6 +10,7 @@ import (
 	"github.com/mr-tron/base58"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
+	"github.com/tbd54566975/ssi-service/pkg/service/common"
 
 	"github.com/tbd54566975/ssi-service/pkg/service/keystore"
 )
@@ -97,7 +98,7 @@ func (h *keyHandler) GetDID(ctx context.Context, request GetDIDRequest) (*GetDID
 	return &GetDIDResponse{DID: gotDID.DID}, nil
 }
 
-func (h *keyHandler) ListDIDs(ctx context.Context, page *Page) (*ListDIDsResponse, error) {
+func (h *keyHandler) ListDIDs(ctx context.Context, page *common.Page) (*ListDIDsResponse, error) {
 	gotDIDs, err := h.storage.ListDIDsPage(ctx, did.KeyMethod.String(), page, new(DefaultStoredDID))
 	if err != nil {
 		return nil, errors.Wrap(err, "listing did:web DIDs page")
diff --git a/pkg/service/did/model.go b/pkg/service/did/model.go
index 66b62d5..9e47a82 100644
--- a/pkg/service/did/model.go
+++ b/pkg/service/did/model.go
@@ -6,6 +6,7 @@ import (
 	"github.com/TBD54566975/ssi-sdk/crypto"
 	didsdk "github.com/TBD54566975/ssi-sdk/did"
 	"github.com/TBD54566975/ssi-sdk/did/resolution"
+	"github.com/tbd54566975/ssi-service/pkg/service/common"
 )
 
 type GetSupportedMethodsResponse struct {
@@ -62,9 +63,7 @@ type ListDIDsRequest struct {
 	Method  didsdk.Method `json:"method" validate:"required"`
 	Deleted bool          `json:"deleted"`
 
-	// When nil, all DIDs will be returned.
-	PageSize  *int    `json:"pageSize,omitempty"`
-	PageToken *string `json:"pageToken,omitempty"`
+	PageRequest *common.Page
 }
 
 // ListDIDsResponse is the JSON-serializable response for getting all DIDs for a given method
diff --git a/pkg/service/did/service.go b/pkg/service/did/service.go
index dcc786f..0bc47f1 100644
--- a/pkg/service/did/service.go
+++ b/pkg/service/did/service.go
@@ -8,7 +8,6 @@ import (
 	didresolution "github.com/TBD54566975/ssi-sdk/did/resolution"
 	sdkutil "github.com/TBD54566975/ssi-sdk/util"
 	"github.com/pkg/errors"
-
 	"github.com/tbd54566975/ssi-service/config"
 	"github.com/tbd54566975/ssi-service/pkg/service/did/resolution"
 	"github.com/tbd54566975/ssi-service/pkg/service/framework"
@@ -202,18 +201,7 @@ func (s *Service) ListDIDsByMethod(ctx context.Context, request ListDIDsRequest)
 	if request.Deleted {
 		return handler.ListDeletedDIDs(ctx)
 	}
-	const allPages = -1
-
-	var page Page
-	page.Size = new(int)
-	*page.Size = allPages
-	if request.PageSize != nil {
-		page = Page{
-			Token: request.PageToken,
-			Size:  request.PageSize,
-		}
-	}
-	return handler.ListDIDs(ctx, &page)
+	return handler.ListDIDs(ctx, request.PageRequest)
 }
 
 func (s *Service) SoftDeleteDIDByMethod(ctx context.Context, request DeleteDIDRequest) error {
diff --git a/pkg/service/did/storage.go b/pkg/service/did/storage.go
index 04eb0de..cf4d463 100644
--- a/pkg/service/did/storage.go
+++ b/pkg/service/did/storage.go
@@ -10,6 +10,7 @@ import (
 	"github.com/goccy/go-json"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
+	"github.com/tbd54566975/ssi-service/pkg/service/common"
 
 	"github.com/tbd54566975/ssi-service/internal/util"
 	"github.com/tbd54566975/ssi-service/pkg/storage"
@@ -151,30 +152,18 @@ func (ds *Storage) storedDIDs(gotDIDs map[string][]byte, outType StoredDID) []St
 	return out
 }
 
-type Page struct {
-	Token *string
-	Size  *int
-}
-
 type StoredDIDs struct {
 	DIDs          []StoredDID
 	NextPageToken string
 }
 
-func (ds *Storage) ListDIDsPage(ctx context.Context, method string, page *Page, outType StoredDID) (*StoredDIDs, error) {
+func (ds *Storage) ListDIDsPage(ctx context.Context, method string, page *common.Page, outType StoredDID) (*StoredDIDs, error) {
 	ns, err := getNamespaceForMethod(method)
 	if err != nil {
 		return nil, errors.Wrap(err, "getting namespace")
 	}
 
-	token := ""
-	if page != nil && page.Token != nil {
-		token = *page.Token
-	}
-	size := -1
-	if page != nil && page.Size != nil {
-		size = *page.Size
-	}
+	token, size := page.ToStorageArgs()
 
 	gotDIDs, nextPageToken, err := ds.db.ReadPage(ctx, ns, token, size)
 	if err != nil {
diff --git a/pkg/service/did/web.go b/pkg/service/did/web.go
index 8307d92..3dd4b1f 100644
--- a/pkg/service/did/web.go
+++ b/pkg/service/did/web.go
@@ -11,6 +11,7 @@ import (
 	"github.com/mr-tron/base58"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
+	"github.com/tbd54566975/ssi-service/pkg/service/common"
 
 	"github.com/tbd54566975/ssi-service/pkg/service/keystore"
 )
@@ -129,7 +130,7 @@ func (h *webHandler) GetDID(ctx context.Context, request GetDIDRequest) (*GetDID
 	return &GetDIDResponse{DID: gotDID.GetDocument()}, nil
 }
 
-func (h *webHandler) ListDIDs(ctx context.Context, page *Page) (*ListDIDsResponse, error) {
+func (h *webHandler) ListDIDs(ctx context.Context, page *common.Page) (*ListDIDsResponse, error) {
 	gotDIDs, err := h.storage.ListDIDsPage(ctx, did.WebMethod.String(), page, new(DefaultStoredDID))
 	if err != nil {
 		return nil, errors.Wrap(err, "listing did:web DIDs page")
diff --git a/pkg/service/presentation/model/model.go b/pkg/service/presentation/model/model.go
index a447288..fc4fa97 100644
--- a/pkg/service/presentation/model/model.go
+++ b/pkg/service/presentation/model/model.go
@@ -5,6 +5,7 @@ import (
 	"github.com/TBD54566975/ssi-sdk/credential/exchange"
 	"github.com/TBD54566975/ssi-sdk/util"
 	"github.com/goccy/go-json"
+	"github.com/tbd54566975/ssi-service/pkg/server/pagination"
 	"github.com/tbd54566975/ssi-service/pkg/service/common"
 	"go.einride.tech/aip/filtering"
 
@@ -65,7 +66,8 @@ type DeleteSubmissionRequest struct {
 }
 
 type ListSubmissionRequest struct {
-	Filter filtering.Filter
+	Filter      filtering.Filter
+	PageRequest *pagination.PageRequest
 }
 
 type Submission struct {
@@ -94,7 +96,8 @@ func (r Submission) GetSubmission() *exchange.PresentationSubmission {
 }
 
 type ListSubmissionResponse struct {
-	Submissions []Submission `json:"submissions"`
+	Submissions   []Submission `json:"submissions"`
+	NextPageToken string
 }
 
 type ListDefinitionsResponse struct {
diff --git a/pkg/service/presentation/service.go b/pkg/service/presentation/service.go
index 31df61c..35e0690 100644
--- a/pkg/service/presentation/service.go
+++ b/pkg/service/presentation/service.go
@@ -269,13 +269,16 @@ func (s Service) GetSubmission(ctx context.Context, request model.GetSubmissionR
 func (s Service) ListSubmissions(ctx context.Context, request model.ListSubmissionRequest) (*model.ListSubmissionResponse, error) {
 	logrus.Debug("listing presentation submissions")
 
-	subs, err := s.storage.ListSubmissions(ctx, request.Filter)
+	subs, err := s.storage.ListSubmissions(ctx, request.Filter, *request.PageRequest.ToServicePage())
 	if err != nil {
 		return nil, errors.Wrap(err, "fetching submissions from storage")
 	}
 
-	resp := &model.ListSubmissionResponse{Submissions: make([]model.Submission, 0, len(subs))}
-	for _, sub := range subs {
+	resp := &model.ListSubmissionResponse{
+		Submissions:   make([]model.Submission, 0, len(subs.Submissions)),
+		NextPageToken: subs.NextPageToken,
+	}
+	for _, sub := range subs.Submissions {
 		sub := sub // What's this?? see https://github.com/golang/go/wiki/CommonMistakes#using-reference-to-loop-iterator-variable
 		resp.Submissions = append(resp.Submissions, model.ServiceModel(&sub))
 	}
diff --git a/pkg/service/presentation/storage.go b/pkg/service/presentation/storage.go
index 4623247..009d337 100644
--- a/pkg/service/presentation/storage.go
+++ b/pkg/service/presentation/storage.go
@@ -9,6 +9,7 @@ import (
 	"github.com/goccy/go-json"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
+	"github.com/tbd54566975/ssi-service/pkg/service/common"
 	"go.einride.tech/aip/filtering"
 
 	opstorage "github.com/tbd54566975/ssi-service/pkg/service/operation/storage"
@@ -61,10 +62,11 @@ func (ps *Storage) UpdateSubmission(ctx context.Context, id string, approved boo
 	return s, op, nil
 }
 
-func (ps *Storage) ListSubmissions(ctx context.Context, filter filtering.Filter) ([]prestorage.StoredSubmission, error) {
-	allData, err := ps.db.ReadAll(ctx, opsubmission.Namespace)
+func (ps *Storage) ListSubmissions(ctx context.Context, filter filtering.Filter, page common.Page) (*prestorage.StoredSubmissions, error) {
+	token, size := page.ToStorageArgs()
+	allData, nextPageToken, err := ps.db.ReadPage(ctx, opsubmission.Namespace, token, size)
 	if err != nil {
-		return nil, errors.Wrap(err, "reading all data")
+		return nil, errors.Wrap(err, "reading page")
 	}
 
 	shouldInclude, err := storage.NewIncludeFunc(filter)
@@ -87,7 +89,10 @@ func (ps *Storage) ListSubmissions(ctx context.Context, filter filtering.Filter)
 			storedSubmissions = append(storedSubmissions, ss)
 		}
 	}
-	return storedSubmissions, nil
+	return &prestorage.StoredSubmissions{
+		Submissions:   storedSubmissions,
+		NextPageToken: nextPageToken,
+	}, nil
 }
 
 func NewPresentationStorage(db storage.ServiceStorage) (prestorage.Storage, error) {
diff --git a/pkg/service/presentation/storage/storage.go b/pkg/service/presentation/storage/storage.go
index 9f5936a..7c079dc 100644
--- a/pkg/service/presentation/storage/storage.go
+++ b/pkg/service/presentation/storage/storage.go
@@ -6,6 +6,7 @@ import (
 	"github.com/TBD54566975/ssi-sdk/credential"
 	"github.com/TBD54566975/ssi-sdk/credential/exchange"
 	"github.com/pkg/errors"
+	"github.com/tbd54566975/ssi-service/pkg/service/common"
 	opstorage "github.com/tbd54566975/ssi-service/pkg/service/operation/storage"
 	"github.com/tbd54566975/ssi-service/pkg/service/operation/submission"
 	"go.einride.tech/aip/filtering"
@@ -37,6 +38,11 @@ type StoredSubmission struct {
 	VerifiablePresentation credential.VerifiablePresentation `json:"vp"`
 }
 
+type StoredSubmissions struct {
+	Submissions   []StoredSubmission
+	NextPageToken string
+}
+
 func (s StoredSubmission) FilterVariablesMap() map[string]any {
 	return map[string]any{
 		"status": s.Status.String(),
@@ -46,7 +52,7 @@ func (s StoredSubmission) FilterVariablesMap() map[string]any {
 type SubmissionStorage interface {
 	StoreSubmission(ctx context.Context, schema StoredSubmission) error
 	GetSubmission(ctx context.Context, id string) (*StoredSubmission, error)
-	ListSubmissions(ctx context.Context, filter filtering.Filter) ([]StoredSubmission, error)
+	ListSubmissions(ctx context.Context, filter filtering.Filter, page common.Page) (*StoredSubmissions, error)
 	UpdateSubmission(ctx context.Context, id string, approved bool, reason string, submissionID string) (StoredSubmission, opstorage.StoredOperation, error)
 }
 


Test patch:
diff --git a/pkg/server/router/did_test.go b/pkg/server/router/did_test.go
index 8fe4ca2..ce466b3 100644
--- a/pkg/server/router/did_test.go
+++ b/pkg/server/router/did_test.go
@@ -8,6 +8,7 @@ import (
 	"github.com/TBD54566975/ssi-sdk/crypto"
 	didsdk "github.com/TBD54566975/ssi-sdk/did"
 	"github.com/stretchr/testify/assert"
+	"github.com/tbd54566975/ssi-service/pkg/service/common"
 	"github.com/tbd54566975/ssi-service/pkg/testutil"
 
 	"github.com/tbd54566975/ssi-service/config"
@@ -32,7 +33,6 @@ func TestDIDRouter(t *testing.T) {
 
 	for _, test := range testutil.TestDatabases {
 		t.Run(test.Name, func(t *testing.T) {
-
 			// TODO: Fix pagesize issue on redis - https://github.com/TBD54566975/ssi-service/issues/538
 			if !strings.Contains(test.Name, "Redis") {
 				t.Run("List DIDs supports paging", func(tt *testing.T) {
@@ -50,8 +50,10 @@ func TestDIDRouter(t *testing.T) {
 					one := 1
 					listDIDsResponse1, err := didService.ListDIDsByMethod(context.Background(),
 						did.ListDIDsRequest{
-							Method:   didsdk.KeyMethod,
-							PageSize: &one,
+							Method: didsdk.KeyMethod,
+							PageRequest: &common.Page{
+								Size: one,
+							},
 						})
 
 					assert.NoError(tt, err)
@@ -60,9 +62,11 @@ func TestDIDRouter(t *testing.T) {
 
 					listDIDsResponse2, err := didService.ListDIDsByMethod(context.Background(),
 						did.ListDIDsRequest{
-							Method:    didsdk.KeyMethod,
-							PageSize:  &one,
-							PageToken: &listDIDsResponse1.NextPageToken,
+							Method: didsdk.KeyMethod,
+							PageRequest: &common.Page{
+								Size:  one,
+								Token: listDIDsResponse1.NextPageToken,
+							},
 						})
 
 					assert.NoError(tt, err)
diff --git a/pkg/server/server_presentation_test.go b/pkg/server/server_presentation_test.go
index 3af130d..cd12b9d 100644
--- a/pkg/server/server_presentation_test.go
+++ b/pkg/server/server_presentation_test.go
@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/url"
+	"strings"
 	"testing"
 
 	"github.com/TBD54566975/ssi-sdk/credential"
@@ -376,6 +377,152 @@ func TestPresentationAPI(t *testing.T) {
 					assert.Empty(ttt, resp.Submissions)
 				})
 
+				tt.Run("List submissions invalid page size fails", func(ttt *testing.T) {
+					s := test.ServiceStorage(t)
+					pRouter, _ := setupPresentationRouter(ttt, s)
+
+					w := httptest.NewRecorder()
+					badParams := url.Values{
+						"pageSize": []string{"-1"},
+					}
+					req := httptest.NewRequest(http.MethodGet, "https://ssi-service.com/v1/presentations/submissions?"+badParams.Encode(), nil)
+					c := newRequestContextWithURLValues(w, req, badParams)
+					pRouter.ListSubmissions(c)
+
+					assert.Contains(tt, w.Body.String(), "'pageSize' must be greater than 0")
+				})
+
+				tt.Run("List submissions made up token fails", func(ttt *testing.T) {
+					s := test.ServiceStorage(t)
+					pRouter, _ := setupPresentationRouter(ttt, s)
+
+					w := httptest.NewRecorder()
+					badParams := url.Values{
+						"pageSize":  []string{"1"},
+						"pageToken": []string{"made up token"},
+					}
+					req := httptest.NewRequest(http.MethodGet, "https://ssi-service.com/v1/presentations/submissions?"+badParams.Encode(), nil)
+					c := newRequestContextWithURLValues(w, req, badParams)
+					pRouter.ListSubmissions(c)
+
+					assert.Contains(tt, w.Body.String(), "token value cannot be decoded")
+				})
+
+				tt.Run("List submissions pagination", func(ttt *testing.T) {
+					// TODO: Fix pagesize issue on redis - https://github.com/TBD54566975/ssi-service/issues/538
+					if strings.Contains(test.Name, "Redis") {
+						ttt.Skip("skipping pagination test for Redis")
+					}
+					s := test.ServiceStorage(t)
+					pRouter, didService := setupPresentationRouter(ttt, s)
+
+					authorDID := createDID(ttt, didService)
+
+					holderSigner, holderDID := getSigner(ttt)
+					definition := createPresentationDefinition(ttt, pRouter)
+					_ = createSubmission(t, pRouter, definition.PresentationDefinition.ID, authorDID.DID.ID, VerifiableCredential(
+						WithCredentialSubject(credential.CredentialSubject{
+							"additionalName": "McLovin",
+							"dateOfBirth":    "1987-01-02",
+							"familyName":     "Andres",
+							"givenName":      "Uribe",
+							"id":             "did:web:andresuribe.com",
+						})), holderDID, holderSigner)
+
+					mrTeeSigner, mrTeeDID := getSigner(ttt)
+					_ = createSubmission(ttt, pRouter, definition.PresentationDefinition.ID, authorDID.DID.ID, VerifiableCredential(
+						WithCredentialSubject(credential.CredentialSubject{
+							"additionalName": "Mr. T",
+							"dateOfBirth":    "1999-01-02",
+							"familyName":     "Mister",
+							"givenName":      "Tee",
+							"id":             "did:web:mrt.com"})), mrTeeDID, mrTeeSigner)
+
+					w := httptest.NewRecorder()
+					params := url.Values{
+						"pageSize": []string{"1"},
+					}
+					req := httptest.NewRequest(http.MethodGet, "https://ssi-service.com/v1/presentations/submissions?"+params.Encode(), nil)
+					c := newRequestContextWithURLValues(w, req, params)
+
+					pRouter.ListSubmissions(c)
+
+					var listSubmissionResponse router.ListSubmissionResponse
+					err := json.NewDecoder(w.Body).Decode(&listSubmissionResponse)
+					assert.NoError(tt, err)
+					assert.NotEmpty(tt, listSubmissionResponse.NextPageToken)
+					assert.Len(tt, listSubmissionResponse.Submissions, 1)
+
+					w = httptest.NewRecorder()
+					params["pageToken"] = []string{listSubmissionResponse.NextPageToken}
+					req = httptest.NewRequest(http.MethodGet, "https://ssi-service.com/v1/presentations/submissions?"+params.Encode(), nil)
+					c = newRequestContextWithURLValues(w, req, params)
+
+					pRouter.ListSubmissions(c)
+
+					var listSubmissionsResponse2 router.ListSubmissionResponse
+					err = json.NewDecoder(w.Body).Decode(&listSubmissionsResponse2)
+					assert.NoError(tt, err)
+					assert.Empty(tt, listSubmissionsResponse2.NextPageToken)
+					assert.Len(tt, listSubmissionsResponse2.Submissions, 1)
+				})
+
+				tt.Run("List submissions pagination change query between calls returns error", func(ttt *testing.T) {
+					// TODO: Fix pagesize issue on redis - https://github.com/TBD54566975/ssi-service/issues/538
+					if strings.Contains(test.Name, "Redis") {
+						ttt.Skip("skipping pagination test for Redis")
+					}
+					s := test.ServiceStorage(t)
+					pRouter, didService := setupPresentationRouter(ttt, s)
+
+					authorDID := createDID(ttt, didService)
+
+					holderSigner, holderDID := getSigner(ttt)
+					definition := createPresentationDefinition(ttt, pRouter)
+					_ = createSubmission(t, pRouter, definition.PresentationDefinition.ID, authorDID.DID.ID, VerifiableCredential(
+						WithCredentialSubject(credential.CredentialSubject{
+							"additionalName": "McLovin",
+							"dateOfBirth":    "1987-01-02",
+							"familyName":     "Andres",
+							"givenName":      "Uribe",
+							"id":             "did:web:andresuribe.com",
+						})), holderDID, holderSigner)
+
+					mrTeeSigner, mrTeeDID := getSigner(ttt)
+					_ = createSubmission(ttt, pRouter, definition.PresentationDefinition.ID, authorDID.DID.ID, VerifiableCredential(
+						WithCredentialSubject(credential.CredentialSubject{
+							"additionalName": "Mr. T",
+							"dateOfBirth":    "1999-01-02",
+							"familyName":     "Mister",
+							"givenName":      "Tee",
+							"id":             "did:web:mrt.com"})), mrTeeDID, mrTeeSigner)
+
+					w := httptest.NewRecorder()
+					params := url.Values{
+						"pageSize": []string{"1"},
+					}
+					req := httptest.NewRequest(http.MethodGet, "https://ssi-service.com/v1/presentations/submissions?"+params.Encode(), nil)
+					c := newRequestContextWithURLValues(w, req, params)
+
+					pRouter.ListSubmissions(c)
+
+					var listSubmissionResponse router.ListSubmissionResponse
+					err := json.NewDecoder(w.Body).Decode(&listSubmissionResponse)
+					assert.NoError(tt, err)
+					assert.NotEmpty(tt, listSubmissionResponse.NextPageToken)
+					assert.Len(tt, listSubmissionResponse.Submissions, 1)
+
+					w = httptest.NewRecorder()
+					params["pageToken"] = []string{listSubmissionResponse.NextPageToken}
+					params["filter"] = []string{"status=\"pending\""}
+					req = httptest.NewRequest(http.MethodGet, "https://ssi-service.com/v1/presentations/submissions?"+params.Encode(), nil)
+					c = newRequestContextWithURLValues(w, req, params)
+
+					pRouter.ListSubmissions(c)
+					assert.Equal(tt, http.StatusBadRequest, w.Result().StatusCode)
+					assert.Contains(tt, w.Body.String(), "page token must be for the same query")
+				})
+
 				tt.Run("List submissions returns many submissions", func(ttt *testing.T) {
 					s := test.ServiceStorage(t)
 					pRouter, didService := setupPresentationRouter(ttt, s)