Skip to content

Commit f7d2e70

Browse files
authored
lint subgraphs (#11)
1 parent 974a92e commit f7d2e70

File tree

1 file changed

+230
-103
lines changed

1 file changed

+230
-103
lines changed

src/index.ts

+230-103
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,106 @@
1-
import { ApolloClient, gql, InMemoryCache } from "@apollo/client/core/core.cjs";
2-
import * as graphql from '@graphql-eslint/eslint-plugin';
31
import crypto from 'crypto';
4-
import { Linter } from 'eslint';
5-
import type { Config, Context } from "@netlify/functions";
62

7-
const linter = new Linter({cwd: '.',});
3+
import { ApolloClient, InMemoryCache, gql } from '@apollo/client/core/core.cjs';
4+
import * as graphql from '@graphql-eslint/eslint-plugin';
5+
import type { Config, Context } from '@netlify/functions';
6+
import { ESLint, Linter } from 'eslint';
7+
8+
const linter = new Linter({ cwd: '.' });
9+
10+
function getSourceLocationCoordiante(
11+
code: string,
12+
line: number,
13+
column: number,
14+
) {
15+
const lines = code.split('\n').slice(0, line);
16+
const lastLine = lines[lines.length - 1];
17+
return {
18+
line,
19+
column,
20+
byteOffset: [...lines.slice(0, -1), lastLine.slice(0, column)].join('\n')
21+
.length - 1,
22+
};
23+
}
824

925
const apolloClient = new ApolloClient({
10-
uri: Netlify.env.get('APOLLO_STUDIO_URL') ?? 'https://api.apollographql.com/api/graphql',
11-
cache: new InMemoryCache(),
26+
uri:
27+
Netlify.env.get('APOLLO_STUDIO_URL') ??
28+
'https://api.apollographql.com/api/graphql',
29+
cache: new InMemoryCache(),
1230
});
1331

14-
const docQuery = gql`query Doc($graphId: ID!, $hash: SHA256) {
15-
graph(id: $graphId) {
16-
doc(hash: $hash) {
17-
source
32+
const docsQuery = gql`
33+
query CustomChecksExampleDocs($graphId: ID!, $hashes: [SHA256!]!) {
34+
graph(id: $graphId) {
35+
docs(hashes: $hashes) {
36+
hash
37+
source
38+
}
1839
}
1940
}
20-
}`;
41+
`;
2142

22-
const customCheckCallbackMutation = gql`mutation CustomCheckCallback($input: CustomCheckCallbackInput!, $name: String!, $graphId: ID!) {
23-
graph(id: $graphId) {
24-
variant(name: $name) {
25-
customCheckCallback(input: $input) {
26-
__typename
27-
... on CustomCheckResult {
28-
violations {
29-
level
43+
const customCheckCallbackMutation = gql`
44+
mutation CustomCheckCallback(
45+
$input: CustomCheckCallbackInput!
46+
$name: String!
47+
$graphId: ID!
48+
) {
49+
graph(id: $graphId) {
50+
variant(name: $name) {
51+
customCheckCallback(input: $input) {
52+
__typename
53+
... on CustomCheckResult {
54+
violations {
55+
level
56+
message
57+
rule
58+
}
59+
}
60+
... on PermissionError {
61+
message
62+
}
63+
... on TaskError {
64+
message
65+
}
66+
... on ValidationError {
3067
message
31-
rule
3268
}
33-
}
34-
... on PermissionError {
35-
message
36-
}
37-
... on TaskError {
38-
message
39-
}
40-
... on ValidationError {
41-
message
4269
}
4370
}
4471
}
4572
}
46-
}`;
73+
`;
74+
75+
interface Payload {
76+
baseSchema: {
77+
hash: string;
78+
subgraphs?: Array<{ hash: string; name: string }> | null;
79+
};
80+
proposedSchema: {
81+
hash: string;
82+
subgraphs?: Array<{ hash: string; name: string }> | null;
83+
};
84+
checkStep: {
85+
taskId: string;
86+
graphId: string;
87+
graphVariant: string;
88+
workflowId: string;
89+
};
90+
gitContext: {
91+
branch?: string | null;
92+
commit?: string | null;
93+
committer?: string | null;
94+
message?: string | null;
95+
remoteUrl?: string | null;
96+
};
97+
}
4798

4899
export default async (req: Request, context: Context) => {
49100
const hmacSecret = Netlify.env.get('APOLLO_HMAC_TOKEN') || '';
50101
const apiKey = Netlify.env.get('APOLLO_API_KEY') || '';
51102

52-
const payload = await req.text() || '{}';
103+
const payload = (await req.text()) || '{}';
53104
console.log(`Payload: ${payload}`);
54105
const providedSignature = req.headers.get('x-apollo-signature');
55106

@@ -58,90 +109,166 @@ export default async (req: Request, context: Context) => {
58109
const calculatedSignature = `sha256=${hmac.digest('hex')}`;
59110

60111
if (providedSignature === calculatedSignature) {
61-
const event = JSON.parse(payload);
112+
const event = JSON.parse(payload) as Payload;
62113
console.log(`Handling taskId: ${event.checkStep.taskId}`);
63-
const docResult = await apolloClient.query({
64-
query: docQuery,
114+
const changedSubgraphs = (event.proposedSchema.subgraphs ?? []).filter(
115+
(proposedSubgraph) =>
116+
event.baseSchema.subgraphs?.find(
117+
(baseSubgraph) => baseSubgraph.name === proposedSubgraph.name,
118+
)?.hash !== proposedSubgraph.hash,
119+
);
120+
const hashesToCheck = [
121+
event.proposedSchema.hash,
122+
...changedSubgraphs.map((s) => s.hash),
123+
];
124+
console.log(`fetching: ${hashesToCheck}`);
125+
const docsResult = await apolloClient
126+
.query<{
127+
graph: null | {
128+
docs: null | Array<null | { hash: string; source: string }>;
129+
};
130+
}>({
131+
query: docsQuery,
132+
variables: {
133+
graphId: event.checkStep.graphId,
134+
hashes: hashesToCheck,
135+
},
136+
context: {
137+
headers: {
138+
'Content-Type': 'application/json',
139+
'apollographql-client-name': 'custom-checks-example',
140+
'apollographql-client-version': '0.0.1',
141+
'x-api-key': apiKey,
142+
},
143+
},
144+
})
145+
.catch((err) => {
146+
console.error(err);
147+
return { data: { graph: null } };
148+
});
149+
const supergraphSource = docsResult.data.graph?.docs?.find(
150+
(doc) => doc?.hash === event.proposedSchema.hash,
151+
)?.source;
152+
const violations = (
153+
await Promise.all(
154+
changedSubgraphs.map(async (subgraph) => {
155+
const code = docsResult.data.graph?.docs?.find(
156+
(doc) => doc?.hash === subgraph.hash,
157+
)?.source;
158+
if (typeof code !== 'string') {
159+
return null;
160+
}
161+
const eslingConfig: Linter.Config = {
162+
files: ['*.graphql'],
163+
plugins: {
164+
'@graphql-eslint': graphql as unknown as ESLint.Plugin,
165+
},
166+
rules: graphql.flatConfigs['schema-recommended']
167+
.rules as unknown as Linter.RulesRecord,
168+
languageOptions: {
169+
parser: graphql,
170+
parserOptions: {
171+
graphQLConfig: { schema: supergraphSource },
172+
},
173+
},
174+
};
175+
try {
176+
const messages = linter.verify(
177+
code,
178+
eslingConfig,
179+
'schema.graphql',
180+
);
181+
console.log(`eslint messages: ${JSON.stringify(messages)}`);
182+
return messages.map((violation) => {
183+
const startSourceLocationCoordiante = getSourceLocationCoordiante(
184+
code,
185+
violation.line,
186+
violation.column,
187+
);
188+
return {
189+
level:
190+
violation.severity === 2
191+
? ('ERROR' as const)
192+
: ('WARNING' as const),
193+
message: violation.message,
194+
rule: violation.ruleId ?? 'unknown',
195+
sourceLocations: [
196+
{
197+
subgraphName: subgraph.name,
198+
start: startSourceLocationCoordiante,
199+
end:
200+
typeof violation.endLine === 'number' &&
201+
typeof violation.endColumn === 'number'
202+
? getSourceLocationCoordiante(
203+
code,
204+
violation.endLine,
205+
violation.endColumn,
206+
)
207+
: startSourceLocationCoordiante,
208+
},
209+
],
210+
};
211+
});
212+
} catch (err) {
213+
console.log(`Error: ${err}`);
214+
return null;
215+
}
216+
}),
217+
)
218+
).flat();
219+
220+
console.log(
221+
'variables',
222+
JSON.stringify({
223+
graphId: event.checkStep.graphId,
224+
name: event.checkStep.graphVariant,
225+
input: {
226+
taskId: event.checkStep.taskId,
227+
workflowId: event.checkStep.workflowId,
228+
status: violations.some(
229+
(violation) => violation === null || violation.level === 'ERROR',
230+
)
231+
? 'FAILURE'
232+
: 'SUCCESS',
233+
violations: violations.filter((v): v is NonNullable<typeof v> => !!v),
234+
},
235+
}),
236+
);
237+
const callbackResult = await apolloClient.mutate({
238+
mutation: customCheckCallbackMutation,
239+
errorPolicy: 'all',
65240
variables: {
66241
graphId: event.checkStep.graphId,
67-
// supergraph hash
68-
hash: event.proposedSchema.hash,
242+
name: event.checkStep.graphVariant,
243+
input: {
244+
taskId: event.checkStep.taskId,
245+
workflowId: event.checkStep.workflowId,
246+
status: violations.some(
247+
(violation) => violation === null || violation.level === 'ERROR',
248+
)
249+
? 'FAILURE'
250+
: 'SUCCESS',
251+
violations: violations.filter((v): v is NonNullable<typeof v> => !!v),
252+
},
69253
},
70254
context: {
71255
headers: {
72-
"Content-Type": "application/json",
73-
"apollographql-client-name": "custom-checks-example",
74-
"apollographql-client-version": "0.0.1",
75-
"x-api-key": apiKey
76-
}
77-
}
78-
});
79-
const code = docResult.data.graph.doc.source
80-
81-
// @ts-ignore
82-
const messages = linter.verify(code, {
83-
files: ['*.graphql'],
84-
plugins: {
85-
'@graphql-eslint': { rules: graphql.rules },
86-
},
87-
languageOptions: {
88-
parser: graphql,
89-
parserOptions: {
90-
graphQLConfig: { schema: code },
256+
'Content-Type': 'application/json',
257+
'apollographql-client-name': 'custom-checks-example',
258+
'apollographql-client-version': '0.0.1',
259+
'x-api-key': apiKey,
91260
},
92261
},
93-
rules: graphql.flatConfigs['schema-recommended'].rules,
94-
}, 'schema.graphql');
95-
96-
console.log(`eslint messages: ${JSON.stringify(messages)}`);
97-
98-
const violations = messages.map(violation => ({
99-
// Fail check if a naming convention is violated
100-
level: violation.ruleId === '@graphql-eslint/naming-convention' ? 'ERROR' : 'WARNING',
101-
message: violation.message,
102-
rule: violation.ruleId ?? 'unknown',
103-
sourceLocations: {
104-
start: {
105-
byteOffset: 0,
106-
line: violation.line,
107-
column: violation.column,
108-
},
109-
end: {
110-
byteOffset: 0,
111-
line: violation.endLine,
112-
column: violation.endColumn,
113-
}
114-
}
115-
}));
116-
117-
const callbackResult = await apolloClient.mutate({
118-
mutation: customCheckCallbackMutation,
119-
variables: {
120-
graphId: event.checkStep.graphId,
121-
name: event.checkStep.graphVariant,
122-
input: {
123-
taskId: event.checkStep.taskId,
124-
workflowId: event.checkStep.workflowId,
125-
status: violations.find(violation => violation.level === 'ERROR') !== undefined ? 'FAILURE' : 'SUCCESS',
126-
violations: violations,
127-
}
128-
},
129-
context: {
130-
headers: {
131-
"Content-Type": "application/json",
132-
"apollographql-client-name": "custom-checks-example",
133-
"apollographql-client-version": "0.0.1",
134-
"x-api-key": apiKey
135-
}
136-
}
137-
});
138-
console.log(JSON.stringify(`Callback results: ${JSON.stringify(callbackResult)}`));
262+
});
263+
console.log(
264+
JSON.stringify(`Callback results: ${JSON.stringify(callbackResult)}`),
265+
);
139266
return new Response('OK', { status: 200 });
140267
} else {
141268
return new Response('Signature is invalid', { status: 403 });
142269
}
143270
};
144271

145272
export const config: Config = {
146-
path: '/custom-lint'
273+
path: '/custom-lint',
147274
};

0 commit comments

Comments
 (0)