diff options
-rw-r--r-- | app/services/search_service.rb | 2 | ||||
-rw-r--r-- | changelogs/unreleased/41763-search-api.yml | 5 | ||||
-rw-r--r-- | doc/api/search.md | 793 | ||||
-rw-r--r-- | lib/api/api.rb | 1 | ||||
-rw-r--r-- | lib/api/entities.rb | 25 | ||||
-rw-r--r-- | lib/api/search.rb | 110 | ||||
-rw-r--r-- | lib/api/v3/projects.rb | 2 | ||||
-rw-r--r-- | lib/gitlab/project_search_results.rb | 7 | ||||
-rw-r--r-- | lib/gitlab/search_results.rb | 10 | ||||
-rw-r--r-- | lib/gitlab/snippet_search_results.rb | 4 | ||||
-rw-r--r-- | spec/fixtures/api/schemas/public_api/v4/blobs.json | 18 | ||||
-rw-r--r-- | spec/fixtures/api/schemas/public_api/v4/issues.json | 2 | ||||
-rw-r--r-- | spec/fixtures/api/schemas/public_api/v4/merge_requests.json | 2 | ||||
-rw-r--r-- | spec/fixtures/api/schemas/public_api/v4/milestones.json | 24 | ||||
-rw-r--r-- | spec/fixtures/api/schemas/public_api/v4/notes.json | 34 | ||||
-rw-r--r-- | spec/fixtures/api/schemas/public_api/v4/projects.json | 36 | ||||
-rw-r--r-- | spec/fixtures/api/schemas/public_api/v4/snippets.json | 33 | ||||
-rw-r--r-- | spec/lib/gitlab/search_results_spec.rb | 6 | ||||
-rw-r--r-- | spec/requests/api/search_spec.rb | 298 |
19 files changed, 1386 insertions, 26 deletions
diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 1d4d03a8b7d..f565013e29b 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -43,7 +43,7 @@ class SearchService end def search_objects - @search_objects ||= search_results.objects(scope, params[:page]) + @search_objects ||= search_results.objects(scope, params[:page], params[:without_counts]) end private diff --git a/changelogs/unreleased/41763-search-api.yml b/changelogs/unreleased/41763-search-api.yml new file mode 100644 index 00000000000..0a760a66510 --- /dev/null +++ b/changelogs/unreleased/41763-search-api.yml @@ -0,0 +1,5 @@ +--- +title: Add search support into the API +merge_request: 16878 +author: +type: added diff --git a/doc/api/search.md b/doc/api/search.md new file mode 100644 index 00000000000..0c05a12c552 --- /dev/null +++ b/doc/api/search.md @@ -0,0 +1,793 @@ +# Search API + +Every API call to search must be authenticated. + +## Global Search API + +Search globally across the GitLab instance. + +``` +GET /search +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `scope` | string | yes | The scope to search in | +| `search` | string | yes | The search query | + +Search the expression within the specified scope. Currentyly these scopes are supported: projects, issues, merge_requests, milestones, snippet_titles, snippet_blobs + +The response depends on the requested scope. + +### Scope: projects + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=projects&search=flight +``` + +Example response: + +```json +[ + { + "id": 6, + "description": "Nobis sed ipsam vero quod cupiditate veritatis hic.", + "name": "Flight", + "name_with_namespace": "Twitter / Flight", + "path": "flight", + "path_with_namespace": "twitter/flight", + "created_at": "2017-09-05T07:58:01.621Z", + "default_branch": "master", + "tag_list":[], + "ssh_url_to_repo": "ssh://jarka@localhost:2222/twitter/flight.git", + "http_url_to_repo": "http://localhost:3000/twitter/flight.git", + "web_url": "http://localhost:3000/twitter/flight", + "avatar_url": null, + "star_count": 0, + "forks_count": 0, + "last_activity_at": "2018-01-31T09:56:30.902Z" + } +] +``` + +### Scope: issues + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=issues&search=file +``` + +Example response: + +```json +[ + { + "id": 83, + "iid": 1, + "project_id": 12, + "title": "Add file", + "description": "Add first file", + "state": "opened", + "created_at": "2018-01-24T06:02:15.514Z", + "updated_at": "2018-02-06T12:36:23.263Z", + "closed_at": null, + "labels":[], + "milestone": null, + "assignees": [{ + "id": 20, + "name": "Ceola Deckow", + "username": "sammy.collier", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon", + "web_url": "http://localhost:3000/sammy.collier" + }], + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "assignee": { + "id": 20, + "name": "Ceola Deckow", + "username": "sammy.collier", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon", + "web_url": "http://localhost:3000/sammy.collier" + }, + "user_notes_count": 0, + "upvotes": 0, + "downvotes": 0, + "due_date": null, + "confidential": false, + "discussion_locked": null, + "web_url": "http://localhost:3000/h5bp/7bp/subgroup-prj/issues/1", + "time_stats": { + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": null, + "human_total_time_spent": null + } + } +] +``` + +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. + +### Scope: merge_requests + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=merge_requests&search=file +``` + +Example response: + +```json +[ + { + "id": 56, + "iid": 8, + "project_id": 6, + "title": "Add first file", + "description": "This is a test MR to add file", + "state": "opened", + "created_at": "2018-01-22T14:21:50.830Z", + "updated_at": "2018-02-06T12:40:33.295Z", + "target_branch": "master", + "source_branch": "jaja-test", + "upvotes": 0, + "downvotes": 0, + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "assignee": { + "id": 5, + "name": "Jacquelyn Kutch", + "username": "abigail", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/3138c66095ee4bd11a508c2f7f7772da?s=80&d=identicon", + "web_url": "http://localhost:3000/abigail" + }, + "source_project_id": 6, + "target_project_id": 6, + "labels": [ + "ruby", + "tests" + ], + "work_in_progress": false, + "milestone": { + "id": 13, + "iid": 3, + "project_id": 6, + "title": "v2.0", + "description": "Qui aut qui eos dolor beatae itaque tempore molestiae.", + "state": "active", + "created_at": "2017-09-05T07:58:29.099Z", + "updated_at": "2017-09-05T07:58:29.099Z", + "due_date": null, + "start_date": null + }, + "merge_when_pipeline_succeeds": false, + "merge_status": "can_be_merged", + "sha": "78765a2d5e0a43585945c58e61ba2f822e4d090b", + "merge_commit_sha": null, + "user_notes_count": 0, + "discussion_locked": null, + "should_remove_source_branch": null, + "force_remove_source_branch": true, + "web_url": "http://localhost:3000/twitter/flight/merge_requests/8", + "time_stats": { + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": null, + "human_total_time_spent": null + } + } +] +``` + +### Scope: milestones + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=milestones&search=release +``` + +Example response: + +```json +[ + { + "id": 44, + "iid": 1, + "project_id": 12, + "title": "next release", + "description": "Next release milestone", + "state": "active", + "created_at": "2018-02-06T12:43:39.271Z", + "updated_at": "2018-02-06T12:44:01.298Z", + "due_date": "2018-04-18", + "start_date": "2018-02-04" + } +] +``` + +### Scope: snippet_titles + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=snippet_titles&search=sample +``` + +Example response: + +```json +[ + { + "id": 50, + "title": "Sample file", + "file_name": "file.rb", + "description": "Simple ruby file", + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "updated_at": "2018-02-06T12:49:29.104Z", + "created_at": "2017-11-28T08:20:18.071Z", + "project_id": 9, + "web_url": "http://localhost:3000/root/jira-test/snippets/50" + } +] +``` + +### Scope: snippet_blobs + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=snippet_blos&search=test +``` + +Example response: + +```json +[ + { + "id": 50, + "title": "Sample file", + "file_name": "file.rb", + "description": "Simple ruby file", + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "updated_at": "2018-02-06T12:49:29.104Z", + "created_at": "2017-11-28T08:20:18.071Z", + "project_id": 9, + "web_url": "http://localhost:3000/root/jira-test/snippets/50" + } +] +``` + + +## Group Search API + +Search within the specified group. + +If a user is not a member of a group and the group is private, a `GET` request on that group will result to a `404` status code. + +``` +GET /groups/:id/-/search +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `scope` | string | yes | The scope to search in | +| `search` | string | yes | The search query | + +Search the expression within the specified scope. Currentyly these scopes are supported: projects, issues, merge_requests, milestones + +The response depends on the requested scope. + +### Scope: projects + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=projects&search=flight +``` + +Example response: + +```json +[ + { + "id": 6, + "description": "Nobis sed ipsam vero quod cupiditate veritatis hic.", + "name": "Flight", + "name_with_namespace": "Twitter / Flight", + "path": "flight", + "path_with_namespace": "twitter/flight", + "created_at": "2017-09-05T07:58:01.621Z", + "default_branch": "master", + "tag_list":[], + "ssh_url_to_repo": "ssh://jarka@localhost:2222/twitter/flight.git", + "http_url_to_repo": "http://localhost:3000/twitter/flight.git", + "web_url": "http://localhost:3000/twitter/flight", + "avatar_url": null, + "star_count": 0, + "forks_count": 0, + "last_activity_at": "2018-01-31T09:56:30.902Z" + } +] +``` + +### Scope: issues + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=issues&search=file +``` + +Example response: + +```json +[ + { + "id": 83, + "iid": 1, + "project_id": 12, + "title": "Add file", + "description": "Add first file", + "state": "opened", + "created_at": "2018-01-24T06:02:15.514Z", + "updated_at": "2018-02-06T12:36:23.263Z", + "closed_at": null, + "labels":[], + "milestone": null, + "assignees": [{ + "id": 20, + "name": "Ceola Deckow", + "username": "sammy.collier", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon", + "web_url": "http://localhost:3000/sammy.collier" + }], + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "assignee": { + "id": 20, + "name": "Ceola Deckow", + "username": "sammy.collier", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon", + "web_url": "http://localhost:3000/sammy.collier" + }, + "user_notes_count": 0, + "upvotes": 0, + "downvotes": 0, + "due_date": null, + "confidential": false, + "discussion_locked": null, + "web_url": "http://localhost:3000/h5bp/7bp/subgroup-prj/issues/1", + "time_stats": { + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": null, + "human_total_time_spent": null + } + } +] +``` + +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. + +### Scope: merge_requests + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=merge_requests&search=file +``` + +Example response: + +```json +[ + { + "id": 56, + "iid": 8, + "project_id": 6, + "title": "Add first file", + "description": "This is a test MR to add file", + "state": "opened", + "created_at": "2018-01-22T14:21:50.830Z", + "updated_at": "2018-02-06T12:40:33.295Z", + "target_branch": "master", + "source_branch": "jaja-test", + "upvotes": 0, + "downvotes": 0, + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "assignee": { + "id": 5, + "name": "Jacquelyn Kutch", + "username": "abigail", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/3138c66095ee4bd11a508c2f7f7772da?s=80&d=identicon", + "web_url": "http://localhost:3000/abigail" + }, + "source_project_id": 6, + "target_project_id": 6, + "labels": [ + "ruby", + "tests" + ], + "work_in_progress": false, + "milestone": { + "id": 13, + "iid": 3, + "project_id": 6, + "title": "v2.0", + "description": "Qui aut qui eos dolor beatae itaque tempore molestiae.", + "state": "active", + "created_at": "2017-09-05T07:58:29.099Z", + "updated_at": "2017-09-05T07:58:29.099Z", + "due_date": null, + "start_date": null + }, + "merge_when_pipeline_succeeds": false, + "merge_status": "can_be_merged", + "sha": "78765a2d5e0a43585945c58e61ba2f822e4d090b", + "merge_commit_sha": null, + "user_notes_count": 0, + "discussion_locked": null, + "should_remove_source_branch": null, + "force_remove_source_branch": true, + "web_url": "http://localhost:3000/twitter/flight/merge_requests/8", + "time_stats": { + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": null, + "human_total_time_spent": null + } + } +] +``` + +### Scope: milestones + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=milestones&search=release +``` + +Example response: + +```json +[ + { + "id": 44, + "iid": 1, + "project_id": 12, + "title": "next release", + "description": "Next release milestone", + "state": "active", + "created_at": "2018-02-06T12:43:39.271Z", + "updated_at": "2018-02-06T12:44:01.298Z", + "due_date": "2018-04-18", + "start_date": "2018-02-04" + } +] +``` + +## Project Search API + +Search within the specified project. + +If a user is not a member of a project and the project is private, a `GET` request on that project will result to a `404` status code. + +``` +GET /projects/:id/-/search +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `scope` | string | yes | The scope to search in | +| `search` | string | yes | The search query | + +Search the expression within the specified scope. Currentyly these scopes are supported: issues, merge_requests, milestones, notes, wiki_blobs, commits, blobs + +The response depends on the requested scope. + + +### Scope: issues + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/12/-/search?scope=issues&search=file +``` + +Example response: + +```json +[ + { + "id": 83, + "iid": 1, + "project_id": 12, + "title": "Add file", + "description": "Add first file", + "state": "opened", + "created_at": "2018-01-24T06:02:15.514Z", + "updated_at": "2018-02-06T12:36:23.263Z", + "closed_at": null, + "labels":[], + "milestone": null, + "assignees": [{ + "id": 20, + "name": "Ceola Deckow", + "username": "sammy.collier", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon", + "web_url": "http://localhost:3000/sammy.collier" + }], + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "assignee": { + "id": 20, + "name": "Ceola Deckow", + "username": "sammy.collier", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon", + "web_url": "http://localhost:3000/sammy.collier" + }, + "user_notes_count": 0, + "upvotes": 0, + "downvotes": 0, + "due_date": null, + "confidential": false, + "discussion_locked": null, + "web_url": "http://localhost:3000/h5bp/7bp/subgroup-prj/issues/1", + "time_stats": { + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": null, + "human_total_time_spent": null + } + } +] +``` + +**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API. + +### Scope: merge_requests + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=merge_requests&search=file +``` + +Example response: + +```json +[ + { + "id": 56, + "iid": 8, + "project_id": 6, + "title": "Add first file", + "description": "This is a test MR to add file", + "state": "opened", + "created_at": "2018-01-22T14:21:50.830Z", + "updated_at": "2018-02-06T12:40:33.295Z", + "target_branch": "master", + "source_branch": "jaja-test", + "upvotes": 0, + "downvotes": 0, + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "assignee": { + "id": 5, + "name": "Jacquelyn Kutch", + "username": "abigail", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/3138c66095ee4bd11a508c2f7f7772da?s=80&d=identicon", + "web_url": "http://localhost:3000/abigail" + }, + "source_project_id": 6, + "target_project_id": 6, + "labels": [ + "ruby", + "tests" + ], + "work_in_progress": false, + "milestone": { + "id": 13, + "iid": 3, + "project_id": 6, + "title": "v2.0", + "description": "Qui aut qui eos dolor beatae itaque tempore molestiae.", + "state": "active", + "created_at": "2017-09-05T07:58:29.099Z", + "updated_at": "2017-09-05T07:58:29.099Z", + "due_date": null, + "start_date": null + }, + "merge_when_pipeline_succeeds": false, + "merge_status": "can_be_merged", + "sha": "78765a2d5e0a43585945c58e61ba2f822e4d090b", + "merge_commit_sha": null, + "user_notes_count": 0, + "discussion_locked": null, + "should_remove_source_branch": null, + "force_remove_source_branch": true, + "web_url": "http://localhost:3000/twitter/flight/merge_requests/8", + "time_stats": { + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": null, + "human_total_time_spent": null + } + } +] +``` + +### Scope: milestones + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/12/-/search?scope=milestones&search=release +``` + +Example response: + +```json +[ + { + "id": 44, + "iid": 1, + "project_id": 12, + "title": "next release", + "description": "Next release milestone", + "state": "active", + "created_at": "2018-02-06T12:43:39.271Z", + "updated_at": "2018-02-06T12:44:01.298Z", + "due_date": "2018-04-18", + "start_date": "2018-02-04" + } +] +``` + +### Scope: notes + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=notes&search=maxime +``` + +Example response: + +```json +[ + { + "id": 191, + "body": "Harum maxime consequuntur et et deleniti assumenda facilis.", + "attachment": null, + "author": { + "id": 23, + "name": "User 1", + "username": "user1", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/111d68d06e2d317b5a59c2c6c5bad808?s=80&d=identicon", + "web_url": "http://localhost:3000/user1" + }, + "created_at": "2017-09-05T08:01:32.068Z", + "updated_at": "2017-09-05T08:01:32.068Z", + "system": false, + "noteable_id": 22, + "noteable_type": "Issue", + "noteable_iid": 2 + } +] +``` + +### Scope: wiki_blobs + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=wiki_blobs&search=bye +``` + +Example response: + +```json + +[ + { + "basename": "home", + "data": "hello\n\nand bye\n\nend", + "filename": "home.md", + "id": null, + "ref": "master", + "startline": 5 + } +] +``` + +### Scope: commits + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=commits&search=bye +``` + +Example response: + +```json + +[ + { + "id": "4109c2d872d5fdb1ed057400d103766aaea97f98", + "short_id": "4109c2d8", + "title": "goodbye $.browser", + "created_at": "2013-02-18T22:02:54.000Z", + "parent_ids": [ + "59d05353ab575bcc2aa958fe1782e93297de64c9" + ], + "message": "goodbye $.browser\n", + "author_name": "angus croll", + "author_email": "anguscroll@gmail.com", + "authored_date": "2013-02-18T22:02:54.000Z", + "committer_name": "angus croll", + "committer_email": "anguscroll@gmail.com", + "committed_date": "2013-02-18T22:02:54.000Z" + } +] +``` + +### Scope: blobs + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=blobs&search=installation +``` + +Example response: + +```json + +[ + { + "basename": "README", + "data": "```\n\n## Installation\n\nQuick start using the [pre-built", + "filename": "README.md", + "id": null, + "ref": "master", + "startline": 46 + } +] +``` diff --git a/lib/api/api.rb b/lib/api/api.rb index f3f64244589..e953f3d2eca 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -146,6 +146,7 @@ module API mount ::API::Repositories mount ::API::Runner mount ::API::Runners + mount ::API::Search mount ::API::Services mount ::API::Settings mount ::API::SidekiqMetrics diff --git a/lib/api/entities.rb b/lib/api/entities.rb index e13463ec66b..7838de13c56 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -314,24 +314,20 @@ module API end end - class ProjectSnippet < Grape::Entity + class Snippet < Grape::Entity expose :id, :title, :file_name, :description expose :author, using: Entities::UserBasic expose :updated_at, :created_at - - expose :web_url do |snippet, options| + expose :project_id + expose :web_url do |snippet| Gitlab::UrlBuilder.build(snippet) end end - class PersonalSnippet < Grape::Entity - expose :id, :title, :file_name, :description - expose :author, using: Entities::UserBasic - expose :updated_at, :created_at + class ProjectSnippet < Snippet + end - expose :web_url do |snippet| - Gitlab::UrlBuilder.build(snippet) - end + class PersonalSnippet < Snippet expose :raw_url do |snippet| Gitlab::UrlBuilder.build(snippet) + "/raw" end @@ -1168,5 +1164,14 @@ module API class ApplicationWithSecret < Application expose :secret end + + class Blob < Grape::Entity + expose :basename + expose :data + expose :filename + expose :id + expose :ref + expose :startline + end end end diff --git a/lib/api/search.rb b/lib/api/search.rb new file mode 100644 index 00000000000..31121b3ee2d --- /dev/null +++ b/lib/api/search.rb @@ -0,0 +1,110 @@ +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 diff --git a/lib/api/v3/projects.rb b/lib/api/v3/projects.rb index c856ba99f09..7d8b1f369fe 100644 --- a/lib/api/v3/projects.rb +++ b/lib/api/v3/projects.rb @@ -174,7 +174,7 @@ module API use :pagination end 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 = projects.reorder(params[:order_by] => params[:sort]) diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index 4823f703ba4..0846fdc4de3 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -2,14 +2,15 @@ module Gitlab class ProjectSearchResults < SearchResults 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 @project = project @repository_ref = repository_ref.presence || project.default_branch @query = query + @per_page = per_page end - def objects(scope, page = nil) + def objects(scope, page = nil, without_counts = true) case scope when 'notes' notes.page(page).per(per_page) @@ -20,7 +21,7 @@ module Gitlab when 'commits' Kaminari.paginate_array(commits).page(page).per(per_page) else - super(scope, page, false) + super(scope, page, without_counts) end end diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index 7362514167f..5ad219179f3 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -10,6 +10,7 @@ module Gitlab @ref = opts.fetch(:ref, nil) @startline = opts.fetch(:startline, nil) @data = opts.fetch(:data, nil) + @per_page = opts.fetch(:per_page, 20) end def path @@ -21,7 +22,7 @@ module Gitlab end end - attr_reader :current_user, :query + attr_reader :current_user, :query, :per_page # Limit search results by passed projects # It allows us to search only for projects user has access to @@ -33,11 +34,12 @@ module Gitlab # query 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 @limit_projects = limit_projects || Project.all @query = query @default_project_filter = default_project_filter + @per_page = per_page end def objects(scope, page = nil, without_count = true) @@ -153,10 +155,6 @@ module Gitlab 'projects' end - def per_page - 20 - end - def project_ids_relation limit_projects.select(:id).reorder(nil) end diff --git a/lib/gitlab/snippet_search_results.rb b/lib/gitlab/snippet_search_results.rb index 4f86b3e8f73..98c43475303 100644 --- a/lib/gitlab/snippet_search_results.rb +++ b/lib/gitlab/snippet_search_results.rb @@ -9,14 +9,14 @@ module Gitlab @query = query end - def objects(scope, page = nil) + def objects(scope, page = nil, without_counts = true) case scope when 'snippet_titles' snippet_titles.page(page).per(per_page) when 'snippet_blobs' snippet_blobs.page(page).per(per_page) else - super(scope, nil, false) + super(scope, nil, without_counts) end end diff --git a/spec/fixtures/api/schemas/public_api/v4/blobs.json b/spec/fixtures/api/schemas/public_api/v4/blobs.json new file mode 100644 index 00000000000..9cb1eae3762 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/blobs.json @@ -0,0 +1,18 @@ +{ + "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 + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/issues.json b/spec/fixtures/api/schemas/public_api/v4/issues.json index 5c08dbc3b96..61f2c2dc39b 100644 --- a/spec/fixtures/api/schemas/public_api/v4/issues.json +++ b/spec/fixtures/api/schemas/public_api/v4/issues.json @@ -20,7 +20,7 @@ } }, "milestone": { - "type": "object", + "type": ["object", "null"], "properties": { "id": { "type": "integer" }, "iid": { "type": "integer" }, diff --git a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json index 034509091a5..e86176e5316 100644 --- a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json +++ b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json @@ -28,7 +28,7 @@ "additionalProperties": false }, "assignee": { - "type": "object", + "type": ["object", "null"], "properties": { "name": { "type": "string" }, "username": { "type": "string" }, diff --git a/spec/fixtures/api/schemas/public_api/v4/milestones.json b/spec/fixtures/api/schemas/public_api/v4/milestones.json new file mode 100644 index 00000000000..c3c42b6ee60 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/milestones.json @@ -0,0 +1,24 @@ +{ + "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 + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/notes.json b/spec/fixtures/api/schemas/public_api/v4/notes.json new file mode 100644 index 00000000000..6525f7c2c80 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/notes.json @@ -0,0 +1,34 @@ +{ + "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 + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/projects.json b/spec/fixtures/api/schemas/public_api/v4/projects.json new file mode 100644 index 00000000000..d89eeea89a5 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/projects.json @@ -0,0 +1,36 @@ +{ + "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 + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/snippets.json b/spec/fixtures/api/schemas/public_api/v4/snippets.json new file mode 100644 index 00000000000..e37e9704649 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/snippets.json @@ -0,0 +1,33 @@ +{ + "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 + } +} diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index 17b48b3d062..9dbab95f70e 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -20,9 +20,13 @@ describe Gitlab::SearchResults do end 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) end + + it 'returns with counts collection when requested' do + expect(results.objects('projects', 1, false)).not_to be_kind_of(Kaminari::PaginatableWithoutCount) + end end describe '#projects_count' do diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb new file mode 100644 index 00000000000..94a2ad7b44f --- /dev/null +++ b/spec/requests/api/search_spec.rb @@ -0,0 +1,298 @@ +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 |