From 2cf38e5b7bef233be40a4a0b478a2a83d28a79a4 Mon Sep 17 00:00:00 2001 From: Lukas Eipert Date: Thu, 15 Oct 2020 23:44:28 +0200 Subject: [PATCH 01/10] Refactor StartupJS for GraphQL This refactors our StartupJS for GraphQL to be a Apollo Link [0] based application. Instead of manually filling the cache, we build an interceptor which "short-circuits" the Apollo Link pipeline in case the query can be found in StartupJS. In case the query fails, is not cached, has different variables, is done more than once, we skip it down the pipeline. Also if all Startup Queries have been done, it self-disables. We also now batch all StartupJS requests into one GraphQL call. [0]: https://www.apollographql.com/docs/link/overview/ --- app/assets/javascripts/lib/graphql.js | 3 +- .../lib/utils/apollo_startup_js_link.js | 105 ++++++++++++ app/assets/javascripts/repository/index.js | 28 +--- app/views/layouts/_startup_js.html.haml | 8 +- app/views/projects/tree/show.html.haml | 2 +- .../lib/utils/apollo_startup_js_link_spec.js | 151 ++++++++++++++++++ 6 files changed, 264 insertions(+), 33 deletions(-) create mode 100644 app/assets/javascripts/lib/utils/apollo_startup_js_link.js create mode 100644 spec/frontend/lib/utils/apollo_startup_js_link_spec.js diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 0e07f7d8e44a40..e0d9a903e0accc 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -5,6 +5,7 @@ import { ApolloLink } from 'apollo-link'; import { BatchHttpLink } from 'apollo-link-batch-http'; import csrf from '~/lib/utils/csrf'; import PerformanceBarService from '~/performance_bar/services/performance_bar_service'; +import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link'; export const fetchPolicies = { CACHE_FIRST: 'cache-first', @@ -62,7 +63,7 @@ export default (resolvers = {}, config = {}) => { return new ApolloClient({ typeDefs: config.typeDefs, - link: ApolloLink.from([performanceBarLink, uploadsLink]), + link: ApolloLink.from([performanceBarLink, new StartupJSLink(), uploadsLink]), cache: new InMemoryCache({ ...config.cacheConfig, freezeResults: config.assumeImmutableResults, diff --git a/app/assets/javascripts/lib/utils/apollo_startup_js_link.js b/app/assets/javascripts/lib/utils/apollo_startup_js_link.js new file mode 100644 index 00000000000000..011f5e5bd2e79d --- /dev/null +++ b/app/assets/javascripts/lib/utils/apollo_startup_js_link.js @@ -0,0 +1,105 @@ +import { ApolloLink, Observable } from 'apollo-link'; +import { parse } from 'graphql'; + +export class StartupJSLink extends ApolloLink { + constructor() { + super(); + // FetchResult + this.fetchResult = null; + this.queryResults = null; + this.startupCalls = new Map(); + this.fetchCall = window.gl?.startup_graphql_calls?.fetchCall; + this.parseStartupCalls(window.gl?.startup_graphql_calls?.calls || []); + } + + // Extract operationNames from the queries and ensure that we can + // match operationName => element from result array + parseStartupCalls(calls) { + calls.forEach((call, index) => { + const { query, variables } = call; + const operationName = parse(query)?.definitions?.find(x => x.kind === 'OperationDefinition') + ?.name?.value; + + if (operationName) { + this.startupCalls.set(operationName, { + index, + variables, + }); + } + }); + } + + async getResult(index) { + if (!this.queryResults) { + this.queryResults = this.fetchCall.then(res => { + // Handle HTTP errors + if (!res.ok) { + throw new Error('fetchCall failed'); + } + this.fetchResult = res; + return res.json(); + }); + } + + return (await this.queryResults)[index]; + } + + static noopRequest = (operation, forward) => forward(operation); + + disable() { + this.request = StartupJSLink.noopRequest; + this.fetchResult = null; + this.startupCalls = null; + this.queryResults = null; + } + + request(operation, forward) { + // Disable StartupJSLink in case all calls are done or none are set up + if ((this.startupCalls && this.startupCalls.size === 0) || !this.fetchCall) { + this.disable(); + return forward(operation); + } + + const { operationName } = operation; + + // Skip startup call if the operationName doesn't match + if (!this.startupCalls.has(operationName)) { + return forward(operation); + } + + const { variables: startupVariables, index } = this.startupCalls.get(operationName); + this.startupCalls.delete(operationName); + + // Skip startup call if the variables values do not match + const variables = Object.entries(operation.variables || {}); + for (let i = 0; i < variables.length; i += 1) { + const [key, value] = variables[i]; + if (startupVariables[key] !== value) { + return forward(operation); + } + } + + return new Observable(observer => { + this.getResult(index) + .then(result => { + operation.setContext({ response: this.fetchResult }); + // we have data and can send it to back up the link chain + observer.next(result); + observer.complete(); + }) + .catch(() => { + // StartupJS somehow failed, so let's forward it down the pipe + this.disable(); + forward(operation).subscribe({ + next: result => { + observer.next(result); + }, + error: error => { + observer.error(error); + }, + complete: observer.complete.bind(observer), + }); + }); + }); + } +} diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index a62b2d96c547e2..f56b141fe5cec2 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import PathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql'; import { escapeFileUrl } from '../lib/utils/url_utility'; import createRouter from './router'; import App from './components/app.vue'; @@ -19,10 +18,6 @@ export default function setupVueRepositoryList() { const { dataset } = el; const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset; const router = createRouter(projectPath, escapedRef); - const pathRegex = /-\/tree\/[^/]+\/(.+$)/; - const matches = window.location.href.match(pathRegex); - - const currentRoutePath = matches ? matches[1] : ''; apolloProvider.clients.defaultClient.cache.writeData({ data: { @@ -48,28 +43,7 @@ export default function setupVueRepositoryList() { }, }); - if (window.gl.startup_graphql_calls) { - const query = window.gl.startup_graphql_calls.find( - call => call.operationName === 'pathLastCommit', - ); - query.fetchCall - .then(res => res.json()) - .then(res => { - apolloProvider.clients.defaultClient.writeQuery({ - query: PathLastCommitQuery, - data: res.data, - variables: { - projectPath, - ref, - path: currentRoutePath, - }, - }); - }) - .catch(() => {}) - .finally(() => initLastCommitApp()); - } else { - initLastCommitApp(); - } + initLastCommitApp(); router.afterEach(({ params: { path } }) => { setTitle(path, ref, fullName); diff --git a/app/views/layouts/_startup_js.html.haml b/app/views/layouts/_startup_js.html.haml index f312e00c394dc7..ed9025697dadd5 100644 --- a/app/views/layouts/_startup_js.html.haml +++ b/app/views/layouts/_startup_js.html.haml @@ -24,13 +24,13 @@ headers: { "Content-Type": "application/json", 'X-CSRF-Token': "#{form_authenticity_token}" }, }; - gl.startup_graphql_calls = gl.startup_graphql_calls.map(call => ({ - operationName: call.query.match(/^query (.+)\(/)[1], + gl.startup_graphql_calls = { + calls: gl.startup_graphql_calls, fetchCall: fetch(url, { ...opts, credentials: 'same-origin', - body: JSON.stringify(call) + body: JSON.stringify(gl.startup_graphql_calls) }) - })) + } } diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index 4d8c357cee1f44..173a0a61220316 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -1,5 +1,5 @@ - current_route_path = request.fullpath.match(/-\/tree\/[^\/]+\/(.+$)/).to_a[1] -- add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path }) +- add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" }) - breadcrumb_title _("Repository") - @content_class = "limit-container-width" unless fluid_layout diff --git a/spec/frontend/lib/utils/apollo_startup_js_link_spec.js b/spec/frontend/lib/utils/apollo_startup_js_link_spec.js new file mode 100644 index 00000000000000..fd1364fdd348d5 --- /dev/null +++ b/spec/frontend/lib/utils/apollo_startup_js_link_spec.js @@ -0,0 +1,151 @@ +import { ApolloLink, Observable } from 'apollo-link'; +import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link'; + +describe('StartupJSLink', () => { + const FORWARDED_RESPONSE = 'FORWARDED_RESPONSE'; + + const STARTUP_JS_RESPONSE = 'STARTUP_JS_RESPONSE'; + const OPERATION_NAME = 'startupJSQuery'; + const STARTUP_JS_QUERY = `query ${OPERATION_NAME}($id: Int = 3){ + name + id + }`; + + const STARTUP_JS_RESPONSE_TWO = 'STARTUP_JS_RESPONSE_TWO'; + const OPERATION_NAME_TWO = 'startupJSQueryTwo'; + const STARTUP_JS_QUERY_TWO = `query ${OPERATION_NAME_TWO}($id: Int = 3){ + id + name + }`; + + let startupLink; + let link; + + function mockFetchCall(status = 200, response = [STARTUP_JS_RESPONSE]) { + const p = { + ok: status >= 200 && status < 300, + status, + headers: new Headers({ 'Content-Type': 'application/json' }), + statusText: `MOCK-FETCH ${status}`, + clone: () => p, + json: () => Promise.resolve(response), + }; + return Promise.resolve(p); + } + + function mockOperation({ operationName = OPERATION_NAME, variables = { id: 3 } } = {}) { + return { operationName, variables, setContext: () => {} }; + } + + const setupLink = () => { + startupLink = new StartupJSLink(); + link = ApolloLink.from([startupLink, new ApolloLink(() => Observable.of(FORWARDED_RESPONSE))]); + }; + + it('forwards requests if no calls are set up', done => { + setupLink(); + link.request(mockOperation()).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls).toBe(null); + expect(startupLink.request).toEqual(StartupJSLink.noopRequest); + done(); + }); + }); + + it('forwards requests if the operation is not pre-loaded', done => { + window.gl = { + startup_graphql_calls: { + fetchCall: mockFetchCall(), + calls: [{ query: STARTUP_JS_QUERY, variables: { id: 3 } }], + }, + }; + setupLink(); + link.request(mockOperation({ operationName: 'notLoaded' })).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls.size).toBe(1); + done(); + }); + }); + + it('forwards requests if the variables are not matching', done => { + window.gl = { + startup_graphql_calls: { + fetchCall: mockFetchCall(), + calls: [{ query: STARTUP_JS_QUERY, variables: { id: 4 } }], + }, + }; + setupLink(); + link.request(mockOperation()).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + + it('forwards the call if the fetchCall is failing with a HTTP Response', done => { + window.gl = { + startup_graphql_calls: { + fetchCall: mockFetchCall(404), + calls: [{ query: STARTUP_JS_QUERY, variables: { id: 3 } }], + }, + }; + setupLink(); + link.request(mockOperation()).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls).toBe(null); + done(); + }); + }); + + it('forwards the call if the errors (e.g. failing JSON)', done => { + window.gl = { + startup_graphql_calls: { + fetchCall: Promise.reject(new Error('Parsing failed')), + calls: [{ query: STARTUP_JS_QUERY, variables: { id: 3 } }], + }, + }; + setupLink(); + link.request(mockOperation()).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls).toBe(null); + done(); + }); + }); + + it('resolves the request if the operation is matching', done => { + window.gl = { + startup_graphql_calls: { + fetchCall: mockFetchCall(), + calls: [{ query: STARTUP_JS_QUERY, variables: { id: 3 } }], + }, + }; + setupLink(); + link.request(mockOperation()).subscribe(result => { + expect(result).toEqual(STARTUP_JS_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + + it('resolves multiple requests correctly', done => { + window.gl = { + startup_graphql_calls: { + fetchCall: mockFetchCall(200, [STARTUP_JS_RESPONSE, STARTUP_JS_RESPONSE_TWO]), + calls: [ + { query: STARTUP_JS_QUERY, variables: { id: 3 } }, + { query: STARTUP_JS_QUERY_TWO, variables: { id: 3 } }, + ], + }, + }; + setupLink(); + link.request(mockOperation({ operationName: OPERATION_NAME_TWO })).subscribe(result => { + expect(result).toEqual(STARTUP_JS_RESPONSE_TWO); + expect(startupLink.startupCalls.size).toBe(1); + link.request(mockOperation({ operationName: OPERATION_NAME })).subscribe(result2 => { + expect(result2).toEqual(STARTUP_JS_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + }); +}); -- GitLab From ac8a927b47c3f16dcc15a59049befc8ff18ce971 Mon Sep 17 00:00:00 2001 From: Lukas Eipert Date: Thu, 15 Oct 2020 23:49:17 +0200 Subject: [PATCH 02/10] Tree view: add all queries to Startup GraphQL --- .../repository/components/breadcrumbs.vue | 2 +- .../repository/components/tree_content.vue | 2 +- .../javascripts/repository/mixins/preload.js | 2 +- .../queries/repository}/files.query.graphql | 18 +++++++++++++++++- .../repository}/permissions.query.graphql | 2 ++ app/views/projects/tree/show.html.haml | 2 ++ 6 files changed, 24 insertions(+), 4 deletions(-) rename app/{assets/javascripts/repository/queries => graphql/queries/repository}/files.query.graphql (78%) rename app/{assets/javascripts/repository/queries => graphql/queries/repository}/permissions.query.graphql (84%) diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 677cb265942199..a1f1c77df2f70f 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -6,12 +6,12 @@ import { GlDropdownItem, GlIcon, } from '@gitlab/ui'; +import permissionsQuery from 'shared_queries/repository/permissions.query.graphql'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; import { __ } from '../../locale'; import getRefMixin from '../mixins/get_ref'; import projectShortPathQuery from '../queries/project_short_path.query.graphql'; import projectPathQuery from '../queries/project_path.query.graphql'; -import permissionsQuery from '../queries/permissions.query.graphql'; const ROW_TYPES = { header: 'header', diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 78b8baaa75e842..b42f88631b52a3 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -1,9 +1,9 @@