From dc368bb579c1a582faed50768e7fbcc463954c89 Mon Sep 17 00:00:00 2001 From: Ian Good Date: Thu, 29 Dec 2016 15:22:12 -0500 Subject: [PATCH] Support for custom pagination logic (#281) * check pagination interface before query params --- api.go | 80 ++++++++++++++++++++++++++++------------------- api_interfaces.go | 21 ++++++++++++- api_test.go | 34 ++++++++++++++++++++ request.go | 1 + response.go | 47 ++++++++++++++++++++++++++-- 5 files changed, 146 insertions(+), 37 deletions(-) diff --git a/api.go b/api.go index 3345189..aa80848 100644 --- a/api.go +++ b/api.go @@ -22,7 +22,10 @@ const ( defaultContentTypHeader = "application/vnd.api+json" ) -var queryFieldsRegex = regexp.MustCompile(`^fields\[(\w+)\]$`) +var ( + queryPageRegex = regexp.MustCompile(`^page\[(\w+)\]$`) + queryFieldsRegex = regexp.MustCompile(`^fields\[(\w+)\]$`) +) type information struct { prefix string @@ -404,9 +407,15 @@ func (api *API) addResource(prototype jsonapi.MarshalIdentifier, source CRUD) *r func buildRequest(c APIContexter, r *http.Request) Request { req := Request{PlainRequest: r} params := make(map[string][]string) + pagination := make(map[string]string) for key, values := range r.URL.Query() { params[key] = strings.Split(values[0], ",") + pageMatches := queryPageRegex.FindStringSubmatch(key) + if len(pageMatches) > 1 { + pagination[pageMatches[1]] = values[0] + } } + req.Pagination = pagination req.QueryParams = params req.Header = r.Header req.Context = c @@ -427,25 +436,24 @@ func (res *resource) marshalResponse(resp interface{}, w http.ResponseWriter, st } func (res *resource) handleIndex(c APIContexter, w http.ResponseWriter, r *http.Request, info information) error { - pagination := newPaginationQueryParams(r) - if pagination.isValid() { - source, ok := res.source.(PaginatedFindAll) - if !ok { - return NewHTTPError(nil, "Resource does not implement the PaginatedFindAll interface", http.StatusNotFound) - } + if source, ok := res.source.(PaginatedFindAll); ok { + pagination := newPaginationQueryParams(r) - count, response, err := source.PaginatedFindAll(buildRequest(c, r)) - if err != nil { - return err - } + if pagination.isValid() { + count, response, err := source.PaginatedFindAll(buildRequest(c, r)) + if err != nil { + return err + } - paginationLinks, err := pagination.getLinks(r, count, info) - if err != nil { - return err - } + paginationLinks, err := pagination.getLinks(r, count, info) + if err != nil { + return err + } - return res.respondWithPagination(response, info, http.StatusOK, paginationLinks, w, r) + return res.respondWithPagination(response, info, http.StatusOK, paginationLinks, w, r) + } } + source, ok := res.source.(FindAll) if !ok { return NewHTTPError(nil, "Resource does not implement the FindAll interface", http.StatusNotFound) @@ -506,26 +514,23 @@ func (res *resource) handleLinked(c APIContexter, api *API, w http.ResponseWrite request.QueryParams[res.name+"ID"] = []string{id} request.QueryParams[res.name+"Name"] = []string{linked.Name} - // check for pagination, otherwise normal FindAll - pagination := newPaginationQueryParams(r) - if pagination.isValid() { - source, ok := resource.source.(PaginatedFindAll) - if !ok { - return NewHTTPError(nil, "Resource does not implement the PaginatedFindAll interface", http.StatusNotFound) - } + if source, ok := resource.source.(PaginatedFindAll); ok { + // check for pagination, otherwise normal FindAll + pagination := newPaginationQueryParams(r) + if pagination.isValid() { + var count uint + count, response, err := source.PaginatedFindAll(request) + if err != nil { + return err + } - var count uint - count, response, err := source.PaginatedFindAll(request) - if err != nil { - return err - } + paginationLinks, err := pagination.getLinks(r, count, info) + if err != nil { + return err + } - paginationLinks, err := pagination.getLinks(r, count, info) - if err != nil { - return err + return res.respondWithPagination(response, info, http.StatusOK, paginationLinks, w, r) } - - return res.respondWithPagination(response, info, http.StatusOK, paginationLinks, w, r) } source, ok := resource.source.(FindAll) @@ -917,6 +922,15 @@ func (res *resource) respondWith(obj Responder, info information, status int, w data.Meta = meta } + if objWithLinks, ok := obj.(LinksResponder); ok { + baseURL := strings.Trim(info.GetBaseURL(), "/") + requestURL := fmt.Sprintf("%s%s", baseURL, r.URL.Path) + links := objWithLinks.Links(r, requestURL) + if len(links) > 0 { + data.Links = links + } + } + return res.marshalResponse(data, w, status, r) } diff --git a/api_interfaces.go b/api_interfaces.go index 84b7f76..0bf970d 100644 --- a/api_interfaces.go +++ b/api_interfaces.go @@ -1,6 +1,10 @@ package api2go -import "net/http" +import ( + "net/http" + + "github.com/manyminds/api2go/jsonapi" +) // The CRUD interface MUST be implemented in order to use the api2go api. // Use Responder for success status codes and content/meta data. In case of an error, @@ -33,6 +37,14 @@ type CRUD interface { Update(obj interface{}, req Request) (Responder, error) } +// Pagination represents information needed to return pagination links +type Pagination struct { + Next map[string]string + Prev map[string]string + First map[string]string + Last map[string]string +} + // The PaginatedFindAll interface can be optionally implemented to fetch a subset of all records. // Pagination query parameters must be used to limit the result. Pagination URLs will automatically // be generated by the api. You can use a combination of the following 2 query parameters: @@ -88,3 +100,10 @@ type Responder interface { Result() interface{} StatusCode() int } + +// The LinksResponder interface may be used when the response object is able to return +// a set of links for the top-level response object. +type LinksResponder interface { + Responder + Links(*http.Request, string) jsonapi.Links +} diff --git a/api_test.go b/api_test.go index 8af3dfe..84a81f2 100644 --- a/api_test.go +++ b/api_test.go @@ -219,6 +219,17 @@ type fixtureSource struct { func (s *fixtureSource) FindAll(req Request) (Responder, error) { var err error + if _, ok := req.Pagination["custom"]; ok { + return &Response{ + Res: []*Post{}, + Pagination: Pagination{ + Next: map[string]string{"type": "next"}, + Prev: map[string]string{"type": "prev"}, + First: map[string]string{}, + }, + }, nil + } + if limit, ok := req.QueryParams["limit"]; ok { if l, err := strconv.ParseInt(limit[0], 10, 64); err == nil { if s.pointers { @@ -1174,6 +1185,18 @@ var _ = Describe("RestHandler", func() { api2goReq := buildRequest(c, req) Expect(api2goReq.QueryParams).To(Equal(map[string][]string{"sort": {"title", "date"}})) }) + + It("Extracts pagination parameters correctly", func() { + req, err := http.NewRequest("GET", "/v0/posts?page[volume]=one&page[size]=10", nil) + Expect(err).To(BeNil()) + c := &APIContext{} + + api2goReq := buildRequest(c, req) + Expect(api2goReq.Pagination).To(Equal(map[string]string{ + "volume": "one", + "size": "10", + })) + }) }) Context("When using pagination", func() { @@ -1232,6 +1255,17 @@ var _ = Describe("RestHandler", func() { return result } + Context("custom pagination", func() { + It("returns the correct links", func() { + links := doRequest("/v1/posts?page[custom]=test") + Expect(links).To(Equal(map[string]string{ + "next": "/v1/posts?page[custom]=test&page[type]=next", + "prev": "/v1/posts?page[custom]=test&page[type]=prev", + "first": "/v1/posts?page[custom]=test", + })) + }) + }) + Context("number & size links", func() { It("No prev and first on first page, size = 1", func() { links := doRequest("/v1/posts?page[number]=1&page[size]=1") diff --git a/request.go b/request.go index be88314..d250ea9 100644 --- a/request.go +++ b/request.go @@ -6,6 +6,7 @@ import "net/http" type Request struct { PlainRequest *http.Request QueryParams map[string][]string + Pagination map[string]string Header http.Header Context APIContexter } diff --git a/response.go b/response.go index a605943..21e8980 100644 --- a/response.go +++ b/response.go @@ -1,13 +1,22 @@ package api2go +import ( + "fmt" + "net/http" + "net/url" + + "github.com/manyminds/api2go/jsonapi" +) + // The Response struct implements api2go.Responder and can be used as a default // implementation for your responses // you can fill the field `Meta` with all the metadata your application needs // like license, tokens, etc type Response struct { - Res interface{} - Code int - Meta map[string]interface{} + Res interface{} + Code int + Meta map[string]interface{} + Pagination Pagination } // Metadata returns additional meta data @@ -24,3 +33,35 @@ func (r Response) Result() interface{} { func (r Response) StatusCode() int { return r.Code } + +func buildLink(base string, r *http.Request, pagination map[string]string) jsonapi.Link { + params := r.URL.Query() + for k, v := range pagination { + qk := fmt.Sprintf("page[%s]", k) + params.Set(qk, v) + } + if len(params) == 0 { + return jsonapi.Link{Href: base} + } + query, _ := url.QueryUnescape(params.Encode()) + return jsonapi.Link{Href: fmt.Sprintf("%s?%s", base, query)} +} + +// Links returns a jsonapi.Links object to include in the top-level response +func (r Response) Links(req *http.Request, baseURL string) (ret jsonapi.Links) { + ret = make(jsonapi.Links) + + if r.Pagination.Next != nil { + ret["next"] = buildLink(baseURL, req, r.Pagination.Next) + } + if r.Pagination.Prev != nil { + ret["prev"] = buildLink(baseURL, req, r.Pagination.Prev) + } + if r.Pagination.First != nil { + ret["first"] = buildLink(baseURL, req, r.Pagination.First) + } + if r.Pagination.Last != nil { + ret["last"] = buildLink(baseURL, req, r.Pagination.Last) + } + return +}