diff --git a/.env.dist b/.env.dist index 96d8cb9..16dab04 100644 --- a/.env.dist +++ b/.env.dist @@ -1,4 +1,5 @@ API_PORT = 8000 +API_ENDPOINT=http://localhost:8090/api/v1 DB_USER=dev DB_PASSWORD=password diff --git a/db/housing.go b/db/housing.go index 80cf5be..cce955a 100644 --- a/db/housing.go +++ b/db/housing.go @@ -16,11 +16,17 @@ func CreateHousing(housing *models.Housing) (*models.Housing, error) { return housing, nil } -// GetAllHousing get all housings from the database -func GetAllHousing() ([]models.Housing, error) { +// GetHousings get all housings from the database +func GetHousings(limit int, cursor *models.Cursor) ([]models.Housing, error) { var housing []models.Housing - if err := Db.Model(&housing).Order("created_at").Select(); err != nil { + query := Db.Model(&housing) + + if cursor != nil { + query.Where("created_at <= ?", &cursor.CreatedAt).Where("id < ?", &cursor.ID) + } + + if err := query.Order("created_at DESC").Limit(limit).Select(); err != nil { return nil, err } diff --git a/docs/docs.go b/docs/docs.go index 99351c1..1167868 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -52,6 +52,21 @@ var doc = `{ "application/json" ], "summary": "Get all housing", + "parameters": [ + { + "type": "string", + "description": "Per page limit", + "name": "per_page", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Previous request response Pagination-Cursor value", + "name": "cursor", + "in": "path" + } + ], "responses": { "200": { "description": "OK", diff --git a/docs/swagger.json b/docs/swagger.json index 5e4595f..ac117a2 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -35,6 +35,21 @@ "application/json" ], "summary": "Get all housing", + "parameters": [ + { + "type": "string", + "description": "Per page limit", + "name": "per_page", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Previous request response Pagination-Cursor value", + "name": "cursor", + "in": "path" + } + ], "responses": { "200": { "description": "OK", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6b5d845..6dc81bc 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -136,6 +136,16 @@ paths: /housing: get: description: Search all housing + parameters: + - description: Per page limit + in: path + name: per_page + required: true + type: string + - description: Previous request response Pagination-Cursor value + in: path + name: cursor + type: string produces: - application/json responses: diff --git a/fixtures/housings.yml b/fixtures/housings.yml index 092bf1e..cd4b5ea 100644 --- a/fixtures/housings.yml +++ b/fixtures/housings.yml @@ -11,6 +11,7 @@ owner_id: RAW=gen_random_uuid() last_tenant_id: RAW=gen_random_uuid() status_id: 6c7de9e6-eddd-4139-bda7-5852beff8b49 + created_at: 2020-10-16 23:59:59 - type_id: 58fc4ce6-3509-4185-845f-3d9ccda6469a surface_area: 45 @@ -27,6 +28,7 @@ owner_id: RAW=gen_random_uuid() last_tenant_id: RAW=gen_random_uuid() status_id: a04e7587-87a1-4d5a-81c8-286a5fceee39 + created_at: 2020-10-17 23:59:59 - type_id: 58fc4ce6-3509-4185-845f-3d9ccda6469a surface_area: 86.4 @@ -42,6 +44,7 @@ owner_id: RAW=gen_random_uuid() last_tenant_id: RAW=gen_random_uuid() status_id: f2625e4e-d06b-4ee5-ae99-258493643702 + created_at: 2020-10-18 23:59:59 - type_id: 45a74d6b-1249-4c30-9ccd-6d4a3777b794 surface_area: 13 @@ -55,3 +58,100 @@ owner_id: RAW=gen_random_uuid() last_tenant_id: RAW=gen_random_uuid() status_id: a04e7587-87a1-4d5a-81c8-286a5fceee39 + created_at: 2020-10-19 23:59:59 + +- type_id: 58fc4ce6-3509-4185-845f-3d9ccda6469a + surface_area: 86.4 + rent_price: 956.50 + rental_charges: 95 + country: FR + state: Pas-de-Calais + city: Arras + zip: 62000 + street: 89 Rue Méaulens + has_electricity: true + has_gas: true + owner_id: RAW=gen_random_uuid() + last_tenant_id: RAW=gen_random_uuid() + status_id: f2625e4e-d06b-4ee5-ae99-258493643702 + created_at: 2020-10-15 23:59:59 + +- type_id: 58fc4ce6-3509-4185-845f-3d9ccda6469a + surface_area: 86.4 + rent_price: 956.50 + rental_charges: 95 + country: FR + state: Pas-de-Calais + city: Arras + zip: 62000 + street: 89 Rue Méaulens + has_electricity: true + has_gas: true + owner_id: RAW=gen_random_uuid() + last_tenant_id: RAW=gen_random_uuid() + status_id: f2625e4e-d06b-4ee5-ae99-258493643702 + created_at: 2020-10-14 23:59:59 + +- type_id: 58fc4ce6-3509-4185-845f-3d9ccda6469a + surface_area: 86.4 + rent_price: 956.50 + rental_charges: 95 + country: FR + state: Pas-de-Calais + city: Arras + zip: 62000 + street: 89 Rue Méaulens + has_electricity: true + has_gas: true + owner_id: RAW=gen_random_uuid() + last_tenant_id: RAW=gen_random_uuid() + status_id: f2625e4e-d06b-4ee5-ae99-258493643702 + created_at: 2020-10-13 23:59:59 + +- type_id: 58fc4ce6-3509-4185-845f-3d9ccda6469a + surface_area: 86.4 + rent_price: 956.50 + rental_charges: 95 + country: FR + state: Pas-de-Calais + city: Arras + zip: 62000 + street: 89 Rue Méaulens + has_electricity: true + has_gas: true + owner_id: RAW=gen_random_uuid() + last_tenant_id: RAW=gen_random_uuid() + status_id: f2625e4e-d06b-4ee5-ae99-258493643702 + created_at: 2020-10-12 23:59:59 + +- type_id: 58fc4ce6-3509-4185-845f-3d9ccda6469a + surface_area: 86.4 + rent_price: 956.50 + rental_charges: 95 + country: FR + state: Pas-de-Calais + city: Arras + zip: 62000 + street: 89 Rue Méaulens + has_electricity: true + has_gas: true + owner_id: RAW=gen_random_uuid() + last_tenant_id: RAW=gen_random_uuid() + status_id: f2625e4e-d06b-4ee5-ae99-258493643702 + created_at: 2020-10-11 23:59:59 + +- type_id: 58fc4ce6-3509-4185-845f-3d9ccda6469a + surface_area: 86.4 + rent_price: 956.50 + rental_charges: 95 + country: FR + state: Pas-de-Calais + city: Arras + zip: 62000 + street: 89 Rue Méaulens + has_electricity: true + has_gas: true + owner_id: RAW=gen_random_uuid() + last_tenant_id: RAW=gen_random_uuid() + status_id: f2625e4e-d06b-4ee5-ae99-258493643702 + created_at: 2020-10-10 23:59:59 diff --git a/go.mod b/go.mod index cacd7bb..4cee0a6 100644 --- a/go.mod +++ b/go.mod @@ -14,5 +14,8 @@ require ( github.com/rs/cors v1.7.0 github.com/swaggo/http-swagger v0.0.0-20200308142732-58ac5e232fba github.com/swaggo/swag v1.6.7 - golang.org/x/tools v0.0.0-20201015182029-a5d9e455e9c4 // indirect + golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 // indirect + golang.org/x/net v0.0.0-20201020065357-d65d470038a5 // indirect + golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13 // indirect + golang.org/x/tools v0.0.0-20201019175715-b894a3290fff // indirect ) diff --git a/go.sum b/go.sum index f051baf..f7527fc 100644 --- a/go.sum +++ b/go.sum @@ -186,6 +186,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee h1:4yd7jl+vXjalO5ztz6Vc1VADv+S/80LGJmyl1ROJ2AI= golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E= +golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -213,6 +215,8 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201010224723-4f7140c49acb h1:mUVeFHoDKis5nxCAzoAi7E8Ghb86EXh/RK6wtvJIqRY= golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201020065357-d65d470038a5 h1:KrxvpY64uUzANd9wKWr6ZAsufiii93XnvXaeikyCJ2g= +golang.org/x/net v0.0.0-20201020065357-d65d470038a5/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -238,6 +242,8 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211 h1:9UQO31fZ+0aKQOFldThf7BKPMJTiBfWycGh/u3UoO88= golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13 h1:5jaG59Zhd+8ZXe8C+lgiAGqkOaZBruqrWclLkgAww34= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -253,8 +259,8 @@ golang.org/x/tools v0.0.0-20190611222205-d73e1c7e250b h1:/mJ+GKieZA6hFDQGdWZrjj4 golang.org/x/tools v0.0.0-20190611222205-d73e1c7e250b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20201015182029-a5d9e455e9c4 h1:rQWkJiVIyJ3PgiSHL+RXc8xbrK8duU6jG5eeZ9G7nk8= -golang.org/x/tools v0.0.0-20201015182029-a5d9e455e9c4/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= +golang.org/x/tools v0.0.0-20201019175715-b894a3290fff h1:HiwHyqQ9ttqCHuTa++R4wNxOg6MY1hduSDT8j2aXoMM= +golang.org/x/tools v0.0.0-20201019175715-b894a3290fff/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/handlers/housing.go b/handlers/housing.go index ccc97cc..4291f23 100644 --- a/handlers/housing.go +++ b/handlers/housing.go @@ -2,10 +2,14 @@ package handlers import ( "encoding/json" + "fmt" "net/http" + "os" + "strconv" "github.com/qimpl/housing/db" "github.com/qimpl/housing/models" + "github.com/qimpl/housing/utils" "github.com/google/uuid" "github.com/gorilla/mux" @@ -46,15 +50,32 @@ func CreateHousing(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(housing) } -// GetAllHousing returns all the housing found on the request response +// GetHousings returns all the housings found on the database // @Summary Get all housing // @Description Search all housing // @Produce json +// @Param per_page path string true "Per page limit" +// @Param cursor path string false "Previous request response Pagination-Cursor value" // @Success 200 {string} []models.Housing // @Failure 400 {string} models.ErrorResponse // @Router /housing [get] -func GetAllHousing(w http.ResponseWriter, r *http.Request) { - housing, err := db.GetAllHousing() +func GetHousings(w http.ResponseWriter, r *http.Request) { + perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page")) + + var cursor *models.Cursor + var err error + + if cursorParam := r.URL.Query().Get("cursor"); cursorParam != "" { + cursor, err = utils.DecodeCursor(cursorParam) + + if err != nil { + w.WriteHeader(http.StatusBadRequest) + var badRequest *models.BadRequest + json.NewEncoder(w).Encode(badRequest.GetError(err.Error())) + } + } + + housing, err := db.GetHousings(perPage, cursor) if err != nil { w.WriteHeader(http.StatusBadRequest) var badRequest *models.BadRequest @@ -63,6 +84,21 @@ func GetAllHousing(w http.ResponseWriter, r *http.Request) { return } + nextCursor := utils.EncodeCursor(housing[len(housing)-1].ID, housing[len(housing)-1].CreatedAt) + paginationHeader := models.PaginationHeaders{ + Link: []string{ + fmt.Sprintf( + "<%s/housing?per_page=%d&cursor=%s>; rel=\"next\"", + os.Getenv("API_ENDPOINT"), + perPage, + nextCursor, + ), + }, + Cursor: nextCursor, + } + + utils.WritePaginationHeaders(w, &paginationHeader) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(housing) } diff --git a/migrations/20201008143740_create_housing_tables.up.sql b/migrations/20201008143740_create_housing_tables.up.sql index 15d2795..71930ed 100644 --- a/migrations/20201008143740_create_housing_tables.up.sql +++ b/migrations/20201008143740_create_housing_tables.up.sql @@ -42,6 +42,8 @@ CREATE TABLE "housings" ( "updated_at" timestamp DEFAULT CURRENT_TIMESTAMP ); +CREATE INDEX idx_housings_pagination ON housings (created_at, id); + CREATE TRIGGER update_timestamp BEFORE UPDATE ON "housings" FOR EACH ROW diff --git a/models/pagination.go b/models/pagination.go new file mode 100644 index 0000000..87842d6 --- /dev/null +++ b/models/pagination.go @@ -0,0 +1,15 @@ +package models + +import "time" + +// Cursor yo +type Cursor struct { + ID string + CreatedAt time.Time +} + +// PaginationHeaders a +type PaginationHeaders struct { + Link []string + Cursor string +} diff --git a/router/housing.go b/router/housing.go index 103e2f3..361505b 100644 --- a/router/housing.go +++ b/router/housing.go @@ -14,7 +14,8 @@ func createHousingRouter(router *mux.Router) { Methods("POST") housingRouter. - HandleFunc("", handlers.GetAllHousing). + HandleFunc("", handlers.GetHousings). + Queries("per_page", "{per_page:[0-9]+}"). Methods("GET") housingRouter. diff --git a/utils/pagination.go b/utils/pagination.go new file mode 100644 index 0000000..bb11394 --- /dev/null +++ b/utils/pagination.go @@ -0,0 +1,55 @@ +package utils + +import ( + "encoding/base64" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "github.com/qimpl/housing/models" +) + +// DecodeCursor yo +func DecodeCursor(encodedCursor string) (*models.Cursor, error) { + byteArray, err := base64.StdEncoding.DecodeString(encodedCursor) + if err != nil { + return nil, err + } + + cursorArray := strings.Split(string(byteArray), ",") + if len(cursorArray) != 2 { + return nil, errors.New("Given cursor is invalid") + } + + createdAt, err := time.Parse(time.RFC3339Nano, cursorArray[0]) + if err != nil { + return nil, err + } + + return &models.Cursor{ + ID: cursorArray[1], + CreatedAt: createdAt, + }, nil +} + +// EncodeCursor yo +func EncodeCursor(uuid uuid.UUID, t time.Time) string { + return base64.StdEncoding.EncodeToString( + []byte( + fmt.Sprintf( + "%s,%s", + t.Format(time.RFC3339Nano), + uuid.String(), + ), + ), + ) +} + +// WritePaginationHeaders YO +func WritePaginationHeaders(w http.ResponseWriter, paginationHeader *models.PaginationHeaders) { + w.Header().Set("Link", strings.Join(paginationHeader.Link, " ")) + w.Header().Set("Pagination-Cursor", paginationHeader.Cursor) +}