Commit 6c73ac27 authored by Denys Mishunov's avatar Denys Mishunov Committed by Phil Hughes

Add blob resolver to snippet GraphQL type

In order to retrieve single or multiple blobs
from snippets and avoid retrieving all at the
same time, we add a resolver to the blobs field in the
snippet type.

We can now pass a string or an array of files to retrieve.
parent 581e0829
......@@ -151,7 +151,7 @@ export default {
this.newSnippet = false;
},
onSnippetFetch(snippetRes) {
if (snippetRes.data.snippets.edges.length === 0) {
if (snippetRes.data.snippets.nodes.length === 0) {
this.onNewSnippetFetched();
} else {
this.onExistingSnippetFetched();
......
......@@ -23,6 +23,7 @@ export default {
return {
ids: this.snippet.id,
rich: this.activeViewerType === RICH_BLOB_VIEWER,
paths: [this.blob.path],
};
},
update(data) {
......@@ -79,8 +80,10 @@ export default {
},
onContentUpdate(data) {
const { path: blobPath } = this.blob;
const { blobs } = data.snippets.edges[0].node;
const updatedBlobData = blobs.find(blob => blob.path === blobPath);
const {
blobs: { nodes: dataBlobs },
} = data.snippets.nodes[0];
const updatedBlobData = dataBlobs.find(blob => blob.path === blobPath);
return updatedBlobData.richData || updatedBlobData.plainData;
},
},
......
......@@ -18,6 +18,7 @@ import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql';
import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql';
import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql';
import { joinPaths } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
export default {
components: {
......@@ -37,6 +38,7 @@ export default {
},
apollo: {
canCreateSnippet: {
fetchPolicy: fetchPolicies.NO_CACHE,
query() {
return this.snippet.project ? CanCreateProjectSnippet : CanCreatePersonalSnippet;
},
......
......@@ -12,6 +12,7 @@ fragment SnippetBase on Snippet {
httpUrlToRepo
sshUrlToRepo
blobs {
nodes {
binary
name
path
......@@ -26,6 +27,7 @@ fragment SnippetBase on Snippet {
...BlobViewer
}
}
}
userPermissions {
adminSnippet
updateSnippet
......
......@@ -16,7 +16,7 @@ function appFactory(el, Component) {
}
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
defaultClient: createDefaultClient({}, { batchMax: 1 }),
});
const {
......
......@@ -11,9 +11,16 @@ export const getSnippetMixin = {
ids: this.snippetGid,
};
},
update: data => data.snippets.edges[0]?.node,
update: data => {
const res = data.snippets.nodes[0];
if (res) {
res.blobs = res.blobs.nodes;
}
return res;
},
result(res) {
this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault;
this.blobs = res.data.snippets.nodes[0]?.blobs || blobsDefault;
if (this.onSnippetFetch) {
this.onSnippetFetch(res);
}
......
query SnippetBlobContent($ids: [ID!], $rich: Boolean!) {
query SnippetBlobContent($ids: [ID!], $rich: Boolean!, $paths: [String!]) {
snippets(ids: $ids) {
edges {
node {
nodes {
id
blobs {
blobs(paths: $paths) {
nodes {
path
richData @include(if: $rich)
plainData @skip(if: $rich)
......
......@@ -4,8 +4,7 @@
query GetSnippetQuery($ids: [ID!]) {
snippets(ids: $ids) {
edges {
node {
nodes {
...SnippetBase
...SnippetProject
author {
......@@ -13,5 +12,4 @@ query GetSnippetQuery($ids: [ID!]) {
}
}
}
}
}
# frozen_string_literal: true
module Resolvers
module Snippets
class BlobsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
alias_method :snippet, :object
argument :paths, [GraphQL::STRING_TYPE],
required: false,
description: 'Paths of the blobs'
def resolve(**args)
authorize!(snippet)
return [snippet.blob] if snippet.empty_repo?
paths = Array(args.fetch(:paths, []))
if paths.empty?
snippet.blobs
else
snippet.repository.blobs_at(transformed_blob_paths(paths))
end
end
def authorized_resource?(snippet)
Ability.allowed?(context[:current_user], :read_snippet, snippet)
end
private
def transformed_blob_paths(paths)
ref = snippet.default_branch
paths.map { |path| [ref, path] }
end
end
end
end
......@@ -69,10 +69,11 @@ module Types
null: false,
deprecated: { reason: 'Use `blobs`', milestone: '13.3' }
field :blobs, type: [Types::Snippets::BlobType],
field :blobs, type: Types::Snippets::BlobType.connection_type,
description: 'Snippet blobs',
calls_gitaly: true,
null: false
null: true,
resolver: Resolvers::Snippets::BlobsResolver
field :ssh_url_to_repo, type: GraphQL::STRING_TYPE,
description: 'SSH URL to the snippet repository',
......
......@@ -32,15 +32,9 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated
end
def blob
blobs.first
end
return snippet.blob if snippet.empty_repo?
def blobs
if snippet.empty_repo?
[snippet.blob]
else
snippet.blobs
end
blobs.first
end
private
......
......@@ -16250,7 +16250,32 @@ type Snippet implements Noteable {
"""
Snippet blobs
"""
blobs: [SnippetBlob!]!
blobs(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
Paths of the blobs
"""
paths: [String!]
): SnippetBlobConnection
"""
Timestamp this snippet was created
......@@ -16473,6 +16498,41 @@ input SnippetBlobActionInputType {
previousPath: String
}
"""
The connection type for SnippetBlob.
"""
type SnippetBlobConnection {
"""
A list of edges.
"""
edges: [SnippetBlobEdge]
"""
A list of nodes.
"""
nodes: [SnippetBlob]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type SnippetBlobEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: SnippetBlob
}
"""
Represents how the blob content should be displayed
"""
......
......@@ -47549,24 +47549,69 @@
"name": "blobs",
"description": "Snippet blobs",
"args": [
],
{
"name": "paths",
"description": "Paths of the blobs",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SnippetBlob",
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "SnippetBlobConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
......@@ -48220,6 +48265,118 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SnippetBlobConnection",
"description": "The connection type for SnippetBlob.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SnippetBlobEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SnippetBlob",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SnippetBlobEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "SnippetBlob",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SnippetBlobViewer",
......@@ -2303,7 +2303,6 @@ Represents a snippet entry.
| ----- | ---- | ----------- |
| `author` | User | The owner of the snippet |
| `blob` **{warning-solid}** | SnippetBlob! | **Deprecated:** Use `blobs`. Deprecated in 13.3 |
| `blobs` | SnippetBlob! => Array | Snippet blobs |
| `createdAt` | Time! | Timestamp this snippet was created |
| `description` | String | Description of the snippet |
| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` |
......
......@@ -148,17 +148,17 @@ describe('Snippet Edit app', () => {
// Ideally we wouldn't call this method directly, but we don't have a way to trigger
// apollo responses yet.
const loadSnippet = (...edges) => {
if (edges.length) {
const loadSnippet = (...nodes) => {
if (nodes.length) {
wrapper.setData({
snippet: edges[0],
snippet: nodes[0],
});
}
wrapper.vm.onSnippetFetch({
data: {
snippets: {
edges,
nodes,
},
},
});
......
......@@ -140,10 +140,10 @@ describe('Blob Embeddable', () => {
async ({ snippetBlobs, currentBlob, expectedContent }) => {
const apolloData = {
snippets: {
edges: [
nodes: [
{
node: {
blobs: snippetBlobs,
blobs: {
nodes: snippetBlobs,
},
},
],
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Snippets::BlobsResolver do
include GraphqlHelpers
describe '#resolve' do
let_it_be(:current_user) { create(:user) }
let_it_be(:snippet) { create(:personal_snippet, :private, :repository, author: current_user) }
context 'when user is not authorized' do
let(:other_user) { create(:user) }
it 'raises an error' do
expect do
resolve_blobs(snippet, user: other_user)
end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when using no filter' do
it 'returns all snippet blobs' do
expect(resolve_blobs(snippet).map(&:path)).to contain_exactly(*snippet.list_files)
end
end
context 'when using filters' do
context 'when paths is a single string' do
it 'returns an array of files' do
path = 'CHANGELOG'
expect(resolve_blobs(snippet, args: { paths: path }).first.path).to eq(path)
end
end
context 'when paths is an array of string' do
it 'returns an array of files' do
paths = ['CHANGELOG', 'README.md']
expect(resolve_blobs(snippet, args: { paths: paths }).map(&:path)).to contain_exactly(*paths)
end
end
end
end
def resolve_blobs(snippet, user: current_user, args: {})
resolve(described_class, args: args, ctx: { current_user: user }, obj: snippet)
end
end
......@@ -16,6 +16,15 @@ RSpec.describe GitlabSchema.types['Snippet'] do
expect(described_class).to have_graphql_fields(*expected_fields)
end
describe 'blobs field' do
subject { described_class.fields['blobs'] }
it 'returns blobs' do
is_expected.to have_graphql_type(Types::Snippets::BlobType.connection_type)
is_expected.to have_graphql_resolver(Resolvers::Snippets::BlobsResolver)
end
end
context 'when restricted visibility level is set to public' do
let_it_be(:snippet) { create(:personal_snippet, :repository, :public, author: user) }
......@@ -142,9 +151,30 @@ RSpec.describe GitlabSchema.types['Snippet'] do
describe '#blobs' do
let_it_be(:snippet) { create(:personal_snippet, :public, author: user) }
let(:query_blobs) { subject.dig('data', 'snippets', 'edges')[0]['node']['blobs'] }
let(:query_blobs) { subject.dig('data', 'snippets', 'edges')[0].dig('node', 'blobs', 'edges') }
let(:paths) { [] }
let(:query) do
%(
{
snippets {
edges {
node {
blobs(paths: #{paths}) {
edges {
node {
name
path
}
}
}
}
}
}
}
)
end
subject { GitlabSchema.execute(snippet_query_for(field: 'blobs'), context: { current_user: user }).as_json }
subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
shared_examples 'an array' do
it 'returns an array of snippet blobs' do
......@@ -158,8 +188,8 @@ RSpec.describe GitlabSchema.types['Snippet'] do
it_behaves_like 'an array'
it 'contains the first blob from the snippet' do
expect(query_blobs.first['name']).to eq blob.name
expect(query_blobs.first['path']).to eq blob.path
expect(query_blobs.first['node']['name']).to eq blob.name
expect(query_blobs.first['node']['path']).to eq blob.path
end
end
......@@ -170,10 +200,22 @@ RSpec.describe GitlabSchema.types['Snippet'] do
it_behaves_like 'an array'
it 'contains all the blobs from the repository' do
resulting_blobs_names = query_blobs.map { |b| b['name'] }
resulting_blobs_names = query_blobs.map { |b| b['node']['name'] }
expect(resulting_blobs_names).to match_array(blobs.map(&:name))
end
context 'when specific path is set' do
let(:paths) { ['CHANGELOG'] }
it_behaves_like 'an array'
it 'returns specific files' do
resulting_blobs_names = query_blobs.map { |b| b['node']['name'] }
expect(resulting_blobs_names).to match(paths)
end
end
end
end
......
......@@ -163,25 +163,4 @@ RSpec.describe SnippetPresenter do
end
end
end
describe '#blobs' do
let(:snippet) { personal_snippet }
subject { presenter.blobs }
context 'when snippet does not have a repository' do
it 'returns an array with one SnippetBlob' do
expect(subject.size).to eq(1)
expect(subject.first).to eq(snippet.blob)
end
end
context 'when snippet has a repository' do
let(:snippet) { create(:snippet, :repository, author: user) }
it 'returns an array with all repository blobs' do
expect(subject).to match_array(snippet.blobs)
end
end
end
end
......@@ -79,18 +79,20 @@ RSpec.describe 'Creating a Snippet' do
end
shared_examples 'creates snippet' do
it 'returns the created Snippet' do
it 'returns the created Snippet', :aggregate_failures do
expect do
subject
end.to change { Snippet.count }.by(1)
snippet = Snippet.last
created_file_1 = snippet.repository.blob_at('HEAD', file_1[:filePath])
created_file_2 = snippet.repository.blob_at('HEAD', file_2[:filePath])
expect(created_file_1.data).to match(file_1[:content])
expect(created_file_2.data).to match(file_2[:content])
expect(mutation_response['snippet']['title']).to eq(title)
expect(mutation_response['snippet']['description']).to eq(description)
expect(mutation_response['snippet']['visibilityLevel']).to eq(visibility_level)
expect(mutation_response['snippet']['blobs'][0]['plainData']).to match(file_1[:content])
expect(mutation_response['snippet']['blobs'][0]['fileName']).to match(file_1[:file_path])
expect(mutation_response['snippet']['blobs'][1]['plainData']).to match(file_2[:content])
expect(mutation_response['snippet']['blobs'][1]['fileName']).to match(file_2[:file_path])
end
context 'when action is invalid' do
......
......@@ -73,7 +73,6 @@ RSpec.describe 'Updating a Snippet' do
aggregate_failures do
expect(blob_to_update.data).to eq updated_content
expect(blob_to_delete).to be_nil
expect(blob_in_mutation_response(updated_file)['plainData']).to match(updated_content)
expect(mutation_response['snippet']['title']).to eq(updated_title)
expect(mutation_response['snippet']['description']).to eq(updated_description)
expect(mutation_response['snippet']['visibilityLevel']).to eq('public')
......@@ -100,7 +99,6 @@ RSpec.describe 'Updating a Snippet' do
aggregate_failures do
expect(blob_at(updated_file).data).to eq blob_to_update.data
expect(blob_at(deleted_file).data).to eq blob_to_delete.data
expect(blob_in_mutation_response(deleted_file)['plainData']).not_to be_nil
expect(mutation_response['snippet']['title']).to eq(original_title)
expect(mutation_response['snippet']['description']).to eq(original_description)
expect(mutation_response['snippet']['visibilityLevel']).to eq('private')
......@@ -108,10 +106,6 @@ RSpec.describe 'Updating a Snippet' do
end
end
def blob_in_mutation_response(filename)
mutation_response['snippet']['blobs'].select { |blob| blob['name'] == filename }[0]
end
def blob_at(filename)
snippet.repository.blob_at('HEAD', filename)
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