Skip to content

Commit

Permalink
feat: add pagination (#14)
Browse files Browse the repository at this point in the history
  • Loading branch information
tobg8 authored Nov 2, 2024
1 parent e4bf0e7 commit 444efc6
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 21 deletions.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ The APi offers only one endpoint:

The query parameters are:

<http://localhost:5000/repos?q=key:value+key:value&per_page=100&page=1>

- *q* - the query string

___

- *size* - 1..10||>=10||<=10||:20
- *topics* - 1..10||>=10||<=10||:20
- *stars* - 1..10||>=10||<=10||:20
Expand All @@ -59,6 +65,14 @@ The query parameters are:
- *created* - >=2024-01-01||<=2024-01-01||:2024-01-01
- *pushed* - >=2024-01-01||<=2024-01-01||:2024-01-01

___

- *per_page* - number of items per page (default: 100, max 100)

___

- *page* - number of the page (default: 1)

## Examples

- search public repositories with the word `scalingo` (in name, description or topics) and the language `javascript` and the size of the repository is between 1 and 10 KB
Expand Down
36 changes: 35 additions & 1 deletion src/controllers/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package controllers

import (
"encoding/json"
"fmt"
"net/http"
"strconv"

"github.com/Scalingo/sclng-backend-test-v1/src/usecases"
)
Expand All @@ -27,7 +29,17 @@ func (rc *RepositoryController) SearchRepositories(w http.ResponseWriter, r *htt
return
}

repos, err := rc.ru.SearchRepositories(query, language)
perPage := r.URL.Query().Get("per_page")
page := r.URL.Query().Get("page")

err = validatePagination(&perPage, &page)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}

repos, err := rc.ru.SearchRepositories(query, language, perPage, page)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
Expand All @@ -38,3 +50,25 @@ func (rc *RepositoryController) SearchRepositories(w http.ResponseWriter, r *htt
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(repos)
}

func validatePagination(perPage, page *string) error {
if *perPage == "" {
*perPage = "100"
}

pp, err := strconv.Atoi(*perPage)
if err != nil || pp < 0 || pp > 100 {
return fmt.Errorf("per_page must be a number between 0 and 100")
}

if *page == "" {
*page = "1"
}

p, err := strconv.Atoi(*page)
if err != nil || p < 1 {
return fmt.Errorf("page must be a positive number")
}

return nil
}
82 changes: 77 additions & 5 deletions src/controllers/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ type mockRepositoryUseCase struct {
mock.Mock
}

func (m *mockRepositoryUseCase) SearchRepositories(query, language string) (*models.RepositorySearchResponse, error) {
args := m.Called(query)
func (m *mockRepositoryUseCase) SearchRepositories(query, language, perPage, page string) (*models.RepositorySearchResponse, error) {
args := m.Called(query, language, perPage, page)
return args.Get(0).(*models.RepositorySearchResponse), args.Error(1)
}

Expand All @@ -35,7 +35,7 @@ func TestSearchRepositoriesEndpoint(t *testing.T) {
endpoint: "/repositories/search?q=golang+language:go",
mockCall: func(m *mockRepositoryUseCase) {
m.On("ValidateQuery", "golang language:go").Return("go", nil)
m.On("SearchRepositories", "golang language:go").Return(&models.RepositorySearchResponse{
m.On("SearchRepositories", "golang language:go", "go", "100", "1").Return(&models.RepositorySearchResponse{
TotalCount: 1,
Items: []models.Repository{
{FullName: "scalingo/scalingo-test"},
Expand All @@ -48,8 +48,8 @@ func TestSearchRepositoriesEndpoint(t *testing.T) {
"usecase error, return error": {
endpoint: "/repositories/search?q=golang",
mockCall: func(m *mockRepositoryUseCase) {
m.On("ValidateQuery", "golang").Return("", nil)
m.On("SearchRepositories", "golang").Return(&models.RepositorySearchResponse{}, errors.New("usecase error"))
m.On("ValidateQuery", "golang").Return("go", nil)
m.On("SearchRepositories", "golang", "go", "100", "1").Return(&models.RepositorySearchResponse{}, errors.New("usecase error"))
},
expectedStatus: http.StatusInternalServerError,
},
Expand All @@ -60,6 +60,13 @@ func TestSearchRepositoriesEndpoint(t *testing.T) {
},
expectedStatus: http.StatusBadRequest,
},
"invalid per_page, return error": {
endpoint: "/repositories/search?q=golang+language:go&per_page=abc",
mockCall: func(m *mockRepositoryUseCase) {
m.On("ValidateQuery", "golang language:go").Return("go", nil)
},
expectedStatus: http.StatusBadRequest,
},
}

for name, tt := range tests {
Expand All @@ -80,3 +87,68 @@ func TestSearchRepositoriesEndpoint(t *testing.T) {
})
}
}

func TestValidatePagination(t *testing.T) {
tests := map[string]struct {
perPage string
page string
wantPerPage string
wantPage string
wantErr assert.ErrorAssertionFunc
}{
"default values when empty": {
perPage: "",
page: "",
wantPerPage: "100",
wantPage: "1",
wantErr: assert.NoError,
},
"valid values": {
perPage: "50",
page: "2",
wantPerPage: "50",
wantPage: "2",
wantErr: assert.NoError,
},
"invalid per_page": {
perPage: "abc",
page: "1",
wantErr: assert.Error,
},
"per_page too high": {
perPage: "101",
page: "1",
wantErr: assert.Error,
},
"per_page negative": {
perPage: "-1",
page: "1",
wantErr: assert.Error,
},
"invalid page": {
perPage: "100",
page: "abc",
wantErr: assert.Error,
},
"negative page": {
perPage: "100",
page: "-1",
wantErr: assert.Error,
},
"zero page": {
perPage: "100",
page: "0",
wantErr: assert.Error,
},
}

for name, tt := range tests {
t.Run(name, func(t *testing.T) {
perPage := tt.perPage
page := tt.page

err := validatePagination(&perPage, &page)
tt.wantErr(t, err)
})
}
}
2 changes: 2 additions & 0 deletions src/models/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package models
type RepositorySearchResponse struct {
TotalCount int `json:"total_count"`
Count int `json:"count"`
PerPage string `json:"per_page"`
Page string `json:"page"`
IncompleteResults bool `json:"incomplete_results"`
Items []Repository `json:"items"`
}
Expand Down
11 changes: 8 additions & 3 deletions src/repositories/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
)

type GitHubRepository interface {
SearchRepositories(query string) (*models.RepositorySearchResponse, error)
SearchRepositories(query, perPage, page string) (*models.RepositorySearchResponse, error)
GetLanguages(repoFullName string) (models.Languages, error)
}

Expand Down Expand Up @@ -56,8 +56,13 @@ func (gr *githubRepository) doRequest(endpoint string, result interface{}) error
return nil
}

func (gr *githubRepository) SearchRepositories(query string) (*models.RepositorySearchResponse, error) {
endpoint := fmt.Sprintf("%s/search/repositories?q=%s", gr.baseURL, url.QueryEscape(query))
func (gr *githubRepository) SearchRepositories(query, perPage, page string) (*models.RepositorySearchResponse, error) {
endpoint := fmt.Sprintf("%s/search/repositories?q=%s&per_page=%s&page=%s",
gr.baseURL,
url.QueryEscape(query),
url.QueryEscape(perPage),
url.QueryEscape(page),
)

var result models.RepositorySearchResponse
if err := gr.doRequest(endpoint, &result); err != nil {
Expand Down
6 changes: 3 additions & 3 deletions src/repositories/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func TestSearchRepositories(t *testing.T) {
baseURL: "://invalid-url",
httpClient: &http.Client{},
}
_, err := repo.SearchRepositories(tc.queryParam)
_, err := repo.SearchRepositories(tc.queryParam, "100", "1")
assert.Error(t, err)
},
},
Expand All @@ -136,15 +136,15 @@ func TestSearchRepositories(t *testing.T) {
baseURL: "://invalid-url",
httpClient: &http.Client{},
}
_, err := repo.SearchRepositories(tt.queryParam)
_, err := repo.SearchRepositories(tt.queryParam, "100", "1")
tt.wantError(t, err)
return
}

server, repo := setupTestServer(t, tt)
defer server.Close()

result, err := repo.SearchRepositories(tt.queryParam)
result, err := repo.SearchRepositories(tt.queryParam, "100", "1")
tt.wantError(t, err)

if tt.mockStatusCode == http.StatusOK && err == nil {
Expand Down
8 changes: 5 additions & 3 deletions src/usecases/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (

// RepositoryUseCase is the interface for the repository use case
type RepositoryUseCase interface {
SearchRepositories(query string, language string) (*models.RepositorySearchResponse, error)
SearchRepositories(query, language, perPage, page string) (*models.RepositorySearchResponse, error)
ValidateQuery(query string) (language string, err error)
}

Expand All @@ -30,8 +30,8 @@ func NewRepositoryUseCase(gr repositories.GitHubRepository) RepositoryUseCase {
}

// SearchRepositories searches repositories and fetches their languages concurrently
func (ru *repositoryUseCase) SearchRepositories(q string, language string) (*models.RepositorySearchResponse, error) {
repos, err := ru.gr.SearchRepositories(q)
func (ru *repositoryUseCase) SearchRepositories(q, language, perPage, page string) (*models.RepositorySearchResponse, error) {
repos, err := ru.gr.SearchRepositories(q, perPage, page)
if err != nil {
log.Print("error searching repositories: ", err)
return nil, err
Expand Down Expand Up @@ -91,6 +91,8 @@ func (ru *repositoryUseCase) SearchRepositories(q string, language string) (*mod
return &models.RepositorySearchResponse{
TotalCount: repos.TotalCount,
Count: len(clientRepos),
PerPage: perPage,
Page: page,
IncompleteResults: repos.IncompleteResults,
Items: clientRepos,
}, nil
Expand Down
12 changes: 6 additions & 6 deletions src/usecases/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ type mockGitHubRepository struct {
mock.Mock
}

func (m *mockGitHubRepository) SearchRepositories(q string) (*models.RepositorySearchResponse, error) {
args := m.Called(q)
func (m *mockGitHubRepository) SearchRepositories(q, perPage, page string) (*models.RepositorySearchResponse, error) {
args := m.Called(q, perPage, page)
return args.Get(0).(*models.RepositorySearchResponse), args.Error(1)
}

Expand Down Expand Up @@ -58,7 +58,7 @@ func TestSearchRepositories(t *testing.T) {
},
}

m.On("SearchRepositories", "tetris"+query).Return(response, nil)
m.On("SearchRepositories", "tetris"+query, "100", "1").Return(response, nil)
m.On("GetLanguages", "scalingo/scalingo-test").Return(models.Languages{"go": 10}, nil)
},
wantError: assert.NoError,
Expand All @@ -72,7 +72,7 @@ func TestSearchRepositories(t *testing.T) {
"error search": {
query: "golang",
mockCall: func(m *mockGitHubRepository) {
m.On("SearchRepositories", "golang").Return(&models.RepositorySearchResponse{}, errors.New("could not perform search query"))
m.On("SearchRepositories", "golang", "100", "1").Return(&models.RepositorySearchResponse{}, errors.New("could not perform search query"))
},
wantError: assert.Error,
checkResponse: func(t *testing.T, resp *models.RepositorySearchResponse) {
Expand All @@ -89,7 +89,7 @@ func TestSearchRepositories(t *testing.T) {
{FullName: "scalingo/scalingo-test"},
},
}
m.On("SearchRepositories", "tetris"+query).Return(response, nil)
m.On("SearchRepositories", "tetris"+query, "100", "1").Return(response, nil)
m.On("GetLanguages", "scalingo/scalingo-test").Return(models.Languages{}, errors.New("API error"))
},
wantError: assert.Error,
Expand All @@ -107,7 +107,7 @@ func TestSearchRepositories(t *testing.T) {
}

ru := NewRepositoryUseCase(mockRepo)
resp, err := ru.SearchRepositories(tt.query, tt.language)
resp, err := ru.SearchRepositories(tt.query, tt.language, "100", "1")

tt.wantError(t, err)
tt.checkResponse(t, resp)
Expand Down

0 comments on commit 444efc6

Please sign in to comment.