Commit 73819445 authored by Jarka Kadlecová's avatar Jarka Kadlecová Committed by Jarka Kadlecová

Support search in API

parent 0e15a5b8
...@@ -43,7 +43,7 @@ class SearchService ...@@ -43,7 +43,7 @@ class SearchService
end end
def search_objects def search_objects
@search_objects ||= search_results.objects(scope, params[:page]) @search_objects ||= search_results.objects(scope, params[:page], params[:without_counts])
end end
private private
......
---
title: Add search support into the API
merge_request: 16878
author:
type: added
This diff is collapsed.
...@@ -146,6 +146,7 @@ module API ...@@ -146,6 +146,7 @@ module API
mount ::API::Repositories mount ::API::Repositories
mount ::API::Runner mount ::API::Runner
mount ::API::Runners mount ::API::Runners
mount ::API::Search
mount ::API::Services mount ::API::Services
mount ::API::Settings mount ::API::Settings
mount ::API::SidekiqMetrics mount ::API::SidekiqMetrics
......
...@@ -314,24 +314,20 @@ module API ...@@ -314,24 +314,20 @@ module API
end end
end end
class ProjectSnippet < Grape::Entity class Snippet < Grape::Entity
expose :id, :title, :file_name, :description expose :id, :title, :file_name, :description
expose :author, using: Entities::UserBasic expose :author, using: Entities::UserBasic
expose :updated_at, :created_at expose :updated_at, :created_at
expose :project_id
expose :web_url do |snippet, options| expose :web_url do |snippet|
Gitlab::UrlBuilder.build(snippet) Gitlab::UrlBuilder.build(snippet)
end end
end end
class PersonalSnippet < Grape::Entity class ProjectSnippet < Snippet
expose :id, :title, :file_name, :description
expose :author, using: Entities::UserBasic
expose :updated_at, :created_at
expose :web_url do |snippet|
Gitlab::UrlBuilder.build(snippet)
end end
class PersonalSnippet < Snippet
expose :raw_url do |snippet| expose :raw_url do |snippet|
Gitlab::UrlBuilder.build(snippet) + "/raw" Gitlab::UrlBuilder.build(snippet) + "/raw"
end end
...@@ -1168,5 +1164,14 @@ module API ...@@ -1168,5 +1164,14 @@ module API
class ApplicationWithSecret < Application class ApplicationWithSecret < Application
expose :secret expose :secret
end end
class Blob < Grape::Entity
expose :basename
expose :data
expose :filename
expose :id
expose :ref
expose :startline
end
end end
end end
module API
class Search < Grape::API
include PaginationParams
before { authenticate! }
helpers do
SCOPE_ENTITY = {
merge_requests: Entities::MergeRequestBasic,
issues: Entities::IssueBasic,
projects: Entities::BasicProjectDetails,
milestones: Entities::Milestone,
notes: Entities::Note,
commits: Entities::Commit,
blobs: Entities::Blob,
wiki_blobs: Entities::Blob,
snippet_titles: Entities::Snippet,
snippet_blobs: Entities::Snippet
}.freeze
def search(additional_params = {})
search_params = {
scope: params[:scope],
search: params[:search],
snippets: snippets?,
page: params[:page],
per_page: params[:per_page],
without_counts: false
}.merge(additional_params)
results = SearchService.new(current_user, search_params).search_objects
process_results(results)
end
def process_results(results)
case params[:scope]
when 'wiki_blobs'
paginate(results).map { |blob| Gitlab::ProjectSearchResults.parse_search_result(blob) }
when 'blobs'
paginate(results).map { |blob| blob[1] }
else
paginate(results)
end
end
def snippets?
%w(snippet_blobs snippet_titles).include?(params[:scope]).to_s
end
def entity
SCOPE_ENTITY[params[:scope].to_sym]
end
end
resource :search do
desc 'Search on GitLab' do
detail 'This feature was introduced in GitLab 10.5.'
end
params do
requires :search, type: String, desc: 'The expression it should be searched for'
requires :scope, type: String, desc: 'The scope of search, available scopes:
projects, issues, merge_requests, milestones, snippet_titles, snippet_blobs',
values: %w(projects issues merge_requests milestones snippet_titles snippet_blobs)
use :pagination
end
get do
present search, with: entity
end
end
resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Search on GitLab' do
detail 'This feature was introduced in GitLab 10.5.'
end
params do
requires :id, type: String, desc: 'The ID of a group'
requires :search, type: String, desc: 'The expression it should be searched for'
requires :scope, type: String, desc: 'The scope of search, available scopes:
projects, issues, merge_requests, milestones',
values: %w(projects issues merge_requests milestones)
use :pagination
end
get ':id/-/search' do
find_group!(params[:id])
present search(group_id: params[:id]), with: entity
end
end
resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Search on GitLab' do
detail 'This feature was introduced in GitLab 10.5.'
end
params do
requires :id, type: String, desc: 'The ID of a project'
requires :search, type: String, desc: 'The expression it should be searched for'
requires :scope, type: String, desc: 'The scope of search, available scopes:
issues, merge_requests, milestones, notes, wiki_blobs, commits, blobs',
values: %w(issues merge_requests milestones notes wiki_blobs commits blobs)
use :pagination
end
get ':id/-/search' do
find_project!(params[:id])
present search(project_id: params[:id]), with: entity
end
end
end
end
...@@ -174,7 +174,7 @@ module API ...@@ -174,7 +174,7 @@ module API
use :pagination use :pagination
end end
get "/search/:query", requirements: { query: %r{[^/]+} } do get "/search/:query", requirements: { query: %r{[^/]+} } do
search_service = Search::GlobalService.new(current_user, search: params[:query]).execute search_service = ::Search::GlobalService.new(current_user, search: params[:query]).execute
projects = search_service.objects('projects', params[:page], false) projects = search_service.objects('projects', params[:page], false)
projects = projects.reorder(params[:order_by] => params[:sort]) projects = projects.reorder(params[:order_by] => params[:sort])
......
...@@ -2,14 +2,15 @@ module Gitlab ...@@ -2,14 +2,15 @@ module Gitlab
class ProjectSearchResults < SearchResults class ProjectSearchResults < SearchResults
attr_reader :project, :repository_ref attr_reader :project, :repository_ref
def initialize(current_user, project, query, repository_ref = nil) def initialize(current_user, project, query, repository_ref = nil, per_page: 20)
@current_user = current_user @current_user = current_user
@project = project @project = project
@repository_ref = repository_ref.presence || project.default_branch @repository_ref = repository_ref.presence || project.default_branch
@query = query @query = query
@per_page = per_page
end end
def objects(scope, page = nil) def objects(scope, page = nil, without_counts = true)
case scope case scope
when 'notes' when 'notes'
notes.page(page).per(per_page) notes.page(page).per(per_page)
...@@ -20,7 +21,7 @@ module Gitlab ...@@ -20,7 +21,7 @@ module Gitlab
when 'commits' when 'commits'
Kaminari.paginate_array(commits).page(page).per(per_page) Kaminari.paginate_array(commits).page(page).per(per_page)
else else
super(scope, page, false) super(scope, page, without_counts)
end end
end end
......
...@@ -10,6 +10,7 @@ module Gitlab ...@@ -10,6 +10,7 @@ module Gitlab
@ref = opts.fetch(:ref, nil) @ref = opts.fetch(:ref, nil)
@startline = opts.fetch(:startline, nil) @startline = opts.fetch(:startline, nil)
@data = opts.fetch(:data, nil) @data = opts.fetch(:data, nil)
@per_page = opts.fetch(:per_page, 20)
end end
def path def path
...@@ -21,7 +22,7 @@ module Gitlab ...@@ -21,7 +22,7 @@ module Gitlab
end end
end end
attr_reader :current_user, :query attr_reader :current_user, :query, :per_page
# Limit search results by passed projects # Limit search results by passed projects
# It allows us to search only for projects user has access to # It allows us to search only for projects user has access to
...@@ -33,11 +34,12 @@ module Gitlab ...@@ -33,11 +34,12 @@ module Gitlab
# query # query
attr_reader :default_project_filter attr_reader :default_project_filter
def initialize(current_user, limit_projects, query, default_project_filter: false) def initialize(current_user, limit_projects, query, default_project_filter: false, per_page: 20)
@current_user = current_user @current_user = current_user
@limit_projects = limit_projects || Project.all @limit_projects = limit_projects || Project.all
@query = query @query = query
@default_project_filter = default_project_filter @default_project_filter = default_project_filter
@per_page = per_page
end end
def objects(scope, page = nil, without_count = true) def objects(scope, page = nil, without_count = true)
...@@ -153,10 +155,6 @@ module Gitlab ...@@ -153,10 +155,6 @@ module Gitlab
'projects' 'projects'
end end
def per_page
20
end
def project_ids_relation def project_ids_relation
limit_projects.select(:id).reorder(nil) limit_projects.select(:id).reorder(nil)
end end
......
...@@ -9,14 +9,14 @@ module Gitlab ...@@ -9,14 +9,14 @@ module Gitlab
@query = query @query = query
end end
def objects(scope, page = nil) def objects(scope, page = nil, without_counts = true)
case scope case scope
when 'snippet_titles' when 'snippet_titles'
snippet_titles.page(page).per(per_page) snippet_titles.page(page).per(per_page)
when 'snippet_blobs' when 'snippet_blobs'
snippet_blobs.page(page).per(per_page) snippet_blobs.page(page).per(per_page)
else else
super(scope, nil, false) super(scope, nil, without_counts)
end end
end end
......
{
"type": "array",
"items": {
"type": "object",
"properties" : {
"basename": { "type": "string" },
"data": { "type": "string" },
"filename": { "type": ["string"] },
"id": { "type": ["string", "null"] },
"ref": { "type": "string" },
"startline": { "type": "integer" }
},
"required": [
"basename", "data", "filename", "id", "ref", "startline"
],
"additionalProperties": false
}
}
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
} }
}, },
"milestone": { "milestone": {
"type": "object", "type": ["object", "null"],
"properties": { "properties": {
"id": { "type": "integer" }, "id": { "type": "integer" },
"iid": { "type": "integer" }, "iid": { "type": "integer" },
......
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
"additionalProperties": false "additionalProperties": false
}, },
"assignee": { "assignee": {
"type": "object", "type": ["object", "null"],
"properties": { "properties": {
"name": { "type": "string" }, "name": { "type": "string" },
"username": { "type": "string" }, "username": { "type": "string" },
......
{
"type": "array",
"items": {
"type": "object",
"properties" : {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"project_id": { "type": ["integer", "null"] },
"group_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },
"created_at": { "type": "date" },
"updated_at": { "type": "date" },
"start_date": { "type": "date" },
"due_date": { "type": "date" }
},
"required": [
"id", "iid", "title", "description", "state",
"state", "created_at", "updated_at", "start_date", "due_date"
],
"additionalProperties": false
}
}
{
"type": "array",
"items": {
"type": "object",
"properties" : {
"id": { "type": "integer" },
"body": { "type": "string" },
"attachment": { "type": ["string", "null"] },
"author": {
"type": "object",
"properties": {
"name": { "type": "string" },
"username": { "type": "string" },
"id": { "type": "integer" },
"state": { "type": "string" },
"avatar_url": { "type": "uri" },
"web_url": { "type": "uri" }
},
"additionalProperties": false
},
"created_at": { "type": "date" },
"updated_at": { "type": "date" },
"system": { "type": "boolean" },
"noteable_id": { "type": "integer" },
"noteable_iid": { "type": "integer" },
"noteable_type": { "type": "string" }
},
"required": [
"id", "body", "attachment", "author", "created_at", "updated_at",
"system", "noteable_id", "noteable_type"
],
"additionalProperties": false
}
}
{
"type": "array",
"items": {
"type": "object",
"properties" : {
"id": { "type": "integer" },
"name": { "type": "string" },
"name_with_namespace": { "type": "string" },
"description": { "type": ["string", "null"] },
"path": { "type": "string" },
"path_with_namespace": { "type": "string" },
"created_at": { "type": "date" },
"default_branch": { "type": ["string", "null"] },
"tag_list": {
"type": "array",
"items": {
"type": "string"
}
},
"ssh_url_to_repo": { "type": "string" },
"http_url_to_repo": { "type": "string" },
"web_url": { "type": "string" },
"avatar_url": { "type": ["string", "null"] },
"star_count": { "type": "integer" },
"forks_count": { "type": "integer" },
"last_activity_at": { "type": "date" }
},
"required": [
"id", "name", "name_with_namespace", "description", "path",
"path_with_namespace", "created_at", "default_branch", "tag_list",
"ssh_url_to_repo", "http_url_to_repo", "web_url", "avatar_url",
"star_count", "last_activity_at"
],
"additionalProperties": false
}
}
{
"type": "array",
"items": {
"type": "object",
"properties" : {
"id": { "type": "integer" },
"project_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"file_name": { "type": ["string", "null"] },
"description": { "type": ["string", "null"] },
"web_url": { "type": "string" },
"created_at": { "type": "date" },
"updated_at": { "type": "date" },
"author": {
"type": "object",
"properties": {
"name": { "type": "string" },
"username": { "type": "string" },
"id": { "type": "integer" },
"state": { "type": "string" },
"avatar_url": { "type": "uri" },
"web_url": { "type": "uri" }
},
"additionalProperties": false
}
},
"required": [
"id", "title", "file_name", "description", "web_url",
"created_at", "updated_at", "author"
],
"additionalProperties": false
}
}
...@@ -20,9 +20,13 @@ describe Gitlab::SearchResults do ...@@ -20,9 +20,13 @@ describe Gitlab::SearchResults do
end end
describe '#objects' do describe '#objects' do
it 'returns without_page collection by default' do it 'returns without_counts collection by default' do
expect(results.objects('projects')).to be_kind_of(Kaminari::PaginatableWithoutCount) expect(results.objects('projects')).to be_kind_of(Kaminari::PaginatableWithoutCount)
end end
it 'returns with counts collection when requested' do
expect(results.objects('projects', 1, false)).not_to be_kind_of(Kaminari::PaginatableWithoutCount)
end
end end
describe '#projects_count' do describe '#projects_count' do
......
require 'spec_helper'
describe API::Search do
set(:user) { create(:user) }
set(:group) { create(:group) }
set(:project) { create(:project, :public, name: 'awesome project', group: group) }
set(:repo_project) { create(:project, :public, :repository, group: group) }
shared_examples 'response is correct' do |schema:, size: 1|
it { expect(response).to have_gitlab_http_status(200) }
it { expect(response).to match_response_schema(schema) }
it { expect(response).to include_pagination_headers }
it { expect(json_response.size).to eq(size) }
end
describe 'GET /search' do
context 'when user is not authenticated' do
it 'returns 401 error' do
get api('/search'), scope: 'projects', search: 'awesome'
expect(response).to have_gitlab_http_status(401)
end
end
context 'when scope is not supported' do
it 'returns 400 error' do
get api('/search', user), scope: 'unsupported', search: 'awesome'
expect(response).to have_gitlab_http_status(400)
end
end
context 'when scope is missing' do
it 'returns 400 error' do
get api('/search', user), search: 'awesome'
expect(response).to have_gitlab_http_status(400)
end
end
context 'with correct params' do
context 'for projects scope' do
before do
get api('/search', user), scope: 'projects', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/projects'
end
context 'for issues scope' do
before do
create(:issue, project: project, title: 'awesome issue')
get api('/search', user), scope: 'issues', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/issues'
end
context 'for merge_requests scope' do
before do
create(:merge_request, source_project: repo_project, title: 'awesome mr')
get api('/search', user), scope: 'merge_requests', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests'
end
context 'for milestones scope' do
before do
create(:milestone, project: project, title: 'awesome milestone')
get api('/search', user), scope: 'milestones', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
end
context 'for snippet_titles scope' do
before do
create(:snippet, :public, title: 'awesome snippet', content: 'snippet content')
get api('/search', user), scope: 'snippet_titles', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/snippets'
end
context 'for snippet_blobs scope' do
before do
create(:snippet, :public, title: 'awesome snippet', content: 'snippet content')
get api('/search', user), scope: 'snippet_blobs', search: 'content'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/snippets'
end
end
end
describe "GET /groups/:id/-/search" do
context 'when user is not authenticated' do
it 'returns 401 error' do
get api("/groups/#{group.id}/-/search"), scope: 'projects', search: 'awesome'
expect(response).to have_gitlab_http_status(401)
end
end
context 'when scope is not supported' do
it 'returns 400 error' do
get api("/groups/#{group.id}/-/search", user), scope: 'unsupported', search: 'awesome'
expect(response).to have_gitlab_http_status(400)
end
end
context 'when scope is missing' do
it 'returns 400 error' do
get api("/groups/#{group.id}/-/search", user), search: 'awesome'
expect(response).to have_gitlab_http_status(400)
end
end
context 'when group does not exist' do
it 'returns 404 error' do
get api('/groups/9999/-/search', user), scope: 'issues', search: 'awesome'
expect(response).to have_gitlab_http_status(404)
end
end
context 'when user does can not see the group' do
it 'returns 404 error' do
private_group = create(:group, :private)
get api("/groups/#{private_group.id}/-/search", user), scope: 'issues', search: 'awesome'
expect(response).to have_gitlab_http_status(404)
end
end
context 'with correct params' do
context 'for projects scope' do
before do
get api("/groups/#{group.id}/-/search", user), scope: 'projects', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/projects'
end
context 'for issues scope' do
before do
create(:issue, project: project, title: 'awesome issue')
get api("/groups/#{group.id}/-/search", user), scope: 'issues', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/issues'
end
context 'for merge_requests scope' do
before do
create(:merge_request, source_project: repo_project, title: 'awesome mr')
get api("/groups/#{group.id}/-/search", user), scope: 'merge_requests', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests'
end
context 'for milestones scope' do
before do
create(:milestone, project: project, title: 'awesome milestone')
get api("/groups/#{group.id}/-/search", user), scope: 'milestones', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
end
end
end
describe "GET /projects/:id/search" do
context 'when user is not authenticated' do
it 'returns 401 error' do
get api("/projects/#{project.id}/-/search"), scope: 'issues', search: 'awesome'
expect(response).to have_gitlab_http_status(401)
end
end
context 'when scope is not supported' do
it 'returns 400 error' do
get api("/projects/#{project.id}/-/search", user), scope: 'unsupported', search: 'awesome'
expect(response).to have_gitlab_http_status(400)
end
end
context 'when scope is missing' do
it 'returns 400 error' do
get api("/projects/#{project.id}/-/search", user), search: 'awesome'
expect(response).to have_gitlab_http_status(400)
end
end
context 'when project does not exist' do
it 'returns 404 error' do
get api('/projects/9999/-/search', user), scope: 'issues', search: 'awesome'
expect(response).to have_gitlab_http_status(404)
end
end
context 'when user does can not see the project' do
it 'returns 404 error' do
project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
get api("/projects/#{project.id}/-/search", user), scope: 'issues', search: 'awesome'
expect(response).to have_gitlab_http_status(404)
end
end
context 'with correct params' do
context 'for issues scope' do
before do
create(:issue, project: project, title: 'awesome issue')
get api("/projects/#{project.id}/-/search", user), scope: 'issues', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/issues'
end
context 'for merge_requests scope' do
before do
create(:merge_request, source_project: repo_project, title: 'awesome mr')
get api("/projects/#{repo_project.id}/-/search", user), scope: 'merge_requests', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests'
end
context 'for milestones scope' do
before do
create(:milestone, project: project, title: 'awesome milestone')
get api("/projects/#{project.id}/-/search", user), scope: 'milestones', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
end
context 'for notes scope' do
before do
create(:note_on_merge_request, project: project, note: 'awesome note')
get api("/projects/#{project.id}/-/search", user), scope: 'notes', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/notes'
end
context 'for wiki_blobs scope' do
before do
wiki = create(:project_wiki, project: project)
create(:wiki_page, wiki: wiki, attrs: { title: 'home', content: "Awesome page" })
get api("/projects/#{project.id}/-/search", user), scope: 'wiki_blobs', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/blobs'
end
context 'for commits scope' do
before do
get api("/projects/#{repo_project.id}/-/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/commits'
end
context 'for blobs scope' do
before do
get api("/projects/#{repo_project.id}/-/search", user), scope: 'blobs', search: 'monitors'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/blobs', size: 2
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment