Commit 78e71f16 authored by Lukas 'Eipi' Eipert's avatar Lukas 'Eipi' Eipert Committed by Natalia Tepluhina

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/
parent 441ea69a
......@@ -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,
......
import { ApolloLink, Observable } from 'apollo-link';
import { parse } from 'graphql';
import { isEqual, pickBy } from 'lodash';
/**
* Remove undefined values from object
* @param obj
* @returns {Dictionary<unknown>}
*/
const pickDefinedValues = obj => pickBy(obj, x => x !== undefined);
/**
* Compares two set of variables, order independent
*
* Ignores undefined values (in the top level) and supports arrays etc.
*/
const variablesMatch = (var1 = {}, var2 = {}) => {
return isEqual(pickDefinedValues(var1), pickDefinedValues(var2));
};
export class StartupJSLink extends ApolloLink {
constructor() {
super();
this.startupCalls = new Map();
this.parseStartupCalls(window.gl?.startup_graphql_calls || []);
}
// Extract operationNames from the queries and ensure that we can
// match operationName => element from result array
parseStartupCalls(calls) {
calls.forEach(call => {
const { query, variables, fetchCall } = call;
const operationName = parse(query)?.definitions?.find(x => x.kind === 'OperationDefinition')
?.name?.value;
if (operationName) {
this.startupCalls.set(operationName, {
variables,
fetchCall,
});
}
});
}
static noopRequest = (operation, forward) => forward(operation);
disable() {
this.request = StartupJSLink.noopRequest;
this.startupCalls = 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.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, fetchCall } = this.startupCalls.get(operationName);
this.startupCalls.delete(operationName);
// Skip startup call if the variables values do not match
if (!variablesMatch(startupVariables, operation.variables)) {
return forward(operation);
}
return new Observable(observer => {
fetchCall
.then(response => {
// Handle HTTP errors
if (!response.ok) {
throw new Error('fetchCall failed');
}
operation.setContext({ response });
return response.json();
})
.then(result => {
if (result && (result.errors || !result.data)) {
throw new Error('Received GraphQL error');
}
// we have data and can send it to back up the link chain
observer.next(result);
observer.complete();
})
.catch(() => {
forward(operation).subscribe({
next: result => {
observer.next(result);
},
error: error => {
observer.error(error);
},
complete: observer.complete.bind(observer),
});
});
});
}
}
......@@ -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',
......
<script>
import filesQuery from 'shared_queries/repository/files.query.graphql';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __ } from '../../locale';
import FileTable from './table/index.vue';
import getRefMixin from '../mixins/get_ref';
import filesQuery from '../queries/files.query.graphql';
import projectPathQuery from '../queries/project_path.query.graphql';
import FilePreview from './preview/index.vue';
import { readmeFile } from '../utils/readme';
......
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);
......
import filesQuery from '../queries/files.query.graphql';
import filesQuery from 'shared_queries/repository/files.query.graphql';
import getRefMixin from './get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
fragment PageInfo on PageInfo {
__typename
hasNextPage
hasPreviousPage
startCursor
endCursor
}
fragment TreeEntry on Entry {
__typename
id
sha
name
......@@ -16,10 +23,15 @@ query getFiles(
$nextPageCursor: String
) {
project(fullPath: $projectPath) {
__typename
repository {
__typename
tree(path: $path, ref: $ref) {
__typename
trees(first: $pageSize, after: $nextPageCursor) {
__typename
edges {
__typename
node {
...TreeEntry
webPath
......@@ -30,7 +42,9 @@ query getFiles(
}
}
submodules(first: $pageSize, after: $nextPageCursor) {
__typename
edges {
__typename
node {
...TreeEntry
webUrl
......@@ -42,7 +56,9 @@ query getFiles(
}
}
blobs(first: $pageSize, after: $nextPageCursor) {
__typename
edges {
__typename
node {
...TreeEntry
mode
......
query getPermissions($projectPath: ID!) {
project(fullPath: $projectPath) {
__typename
userPermissions {
__typename
pushCode
forkProject
createMergeRequestIn
......
......@@ -25,7 +25,7 @@
};
gl.startup_graphql_calls = gl.startup_graphql_calls.map(call => ({
operationName: call.query.match(/^query (.+)\(/)[1],
...call,
fetchCall: fetch(url, {
...opts,
credentials: 'same-origin',
......
- 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 || "" })
- add_page_startup_graphql_call('repository/permissions', { projectPath: @project.full_path })
- add_page_startup_graphql_call('repository/files', { nextPageCursor: "", pageSize: 100, projectPath: @project.full_path, ref: current_ref, path: current_route_path || "/"})
- breadcrumb_title _("Repository")
- @content_class = "limit-container-width" unless fluid_layout
......
import { ApolloLink, Observable } from 'apollo-link';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
describe('StartupJSLink', () => {
const FORWARDED_RESPONSE = { data: 'FORWARDED_RESPONSE' };
const STARTUP_JS_RESPONSE = { data: 'STARTUP_JS_RESPONSE' };
const OPERATION_NAME = 'startupJSQuery';
const STARTUP_JS_QUERY = `query ${OPERATION_NAME}($id: Int = 3){
name
id
}`;
const STARTUP_JS_RESPONSE_TWO = { data: 'STARTUP_JS_RESPONSE_TWO' };
const OPERATION_NAME_TWO = 'startupJSQueryTwo';
const STARTUP_JS_QUERY_TWO = `query ${OPERATION_NAME_TWO}($id: Int = 3){
id
name
}`;
const ERROR_RESPONSE = {
data: {
user: null,
},
errors: [
{
path: ['user'],
locations: [{ line: 2, column: 3 }],
extensions: {
message: 'Object not found',
type: 2,
},
},
],
};
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(),
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();
});
});
describe('variable match errors: ', () => {
it('forwards requests if the variables are not matching', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { id: 'NOT_MATCHING' },
},
],
};
setupLink();
link.request(mockOperation()).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('forwards requests if more variables are set in the operation', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
},
],
};
setupLink();
link.request(mockOperation()).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('forwards requests if less variables are set in the operation', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { id: 3, name: 'tanuki' },
},
],
};
setupLink();
link.request(mockOperation({ variables: { id: 3 } })).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('forwards requests if different variables are set', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { name: 'tanuki' },
},
],
};
setupLink();
link.request(mockOperation({ variables: { id: 3 } })).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('forwards requests if array variables have a different order', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { id: [3, 4] },
},
],
};
setupLink();
link.request(mockOperation({ variables: { id: [4, 3] } })).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
});
describe('error handling', () => {
it('forwards the call if the fetchCall is failing with a HTTP Error', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(404),
query: STARTUP_JS_QUERY,
variables: { id: 3 },
},
],
};
setupLink();
link.request(mockOperation()).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('forwards the call if it errors (e.g. failing JSON)', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: Promise.reject(new Error('Parsing failed')),
query: STARTUP_JS_QUERY,
variables: { id: 3 },
},
],
};
setupLink();
link.request(mockOperation()).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('forwards the call if the response contains an error', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(200, ERROR_RESPONSE),
query: STARTUP_JS_QUERY,
variables: { id: 3 },
},
],
};
setupLink();
link.request(mockOperation()).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it("forwards the call if the response doesn't contain a data object", done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(200, { 'no-data': 'yay' }),
query: STARTUP_JS_QUERY,
variables: { id: 3 },
},
],
};
setupLink();
link.request(mockOperation()).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
});
it('resolves the request if the operation is matching', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
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 the request exactly once', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
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);
link.request(mockOperation()).subscribe(result2 => {
expect(result2).toEqual(FORWARDED_RESPONSE);
done();
});
});
});
it('resolves the request if the variables have a different order', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { id: 3, name: 'foo' },
},
],
};
setupLink();
link.request(mockOperation({ variables: { name: 'foo', id: 3 } })).subscribe(result => {
expect(result).toEqual(STARTUP_JS_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('resolves the request if the variables have undefined values', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { name: 'foo' },
},
],
};
setupLink();
link
.request(mockOperation({ variables: { name: 'foo', undef: undefined } }))
.subscribe(result => {
expect(result).toEqual(STARTUP_JS_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('resolves the request if the variables are of an array format', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { id: [3, 4] },
},
],
};
setupLink();
link.request(mockOperation({ variables: { id: [3, 4] } })).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(),
query: STARTUP_JS_QUERY,
variables: { id: 3 },
},
{
fetchCall: mockFetchCall(200, STARTUP_JS_RESPONSE_TWO),
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();
});
});
});
});
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