Skip to content

Commit

Permalink
lint subgraphs (#11)
Browse files Browse the repository at this point in the history
  • Loading branch information
Jephuff authored Sep 27, 2024
1 parent 974a92e commit f7d2e70
Showing 1 changed file with 230 additions and 103 deletions.
333 changes: 230 additions & 103 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,106 @@
import { ApolloClient, gql, InMemoryCache } from "@apollo/client/core/core.cjs";
import * as graphql from '@graphql-eslint/eslint-plugin';
import crypto from 'crypto';
import { Linter } from 'eslint';
import type { Config, Context } from "@netlify/functions";

const linter = new Linter({cwd: '.',});
import { ApolloClient, InMemoryCache, gql } from '@apollo/client/core/core.cjs';
import * as graphql from '@graphql-eslint/eslint-plugin';
import type { Config, Context } from '@netlify/functions';
import { ESLint, Linter } from 'eslint';

const linter = new Linter({ cwd: '.' });

function getSourceLocationCoordiante(
code: string,
line: number,
column: number,
) {
const lines = code.split('\n').slice(0, line);
const lastLine = lines[lines.length - 1];
return {
line,
column,
byteOffset: [...lines.slice(0, -1), lastLine.slice(0, column)].join('\n')
.length - 1,
};
}

const apolloClient = new ApolloClient({
uri: Netlify.env.get('APOLLO_STUDIO_URL') ?? 'https://api.apollographql.com/api/graphql',
cache: new InMemoryCache(),
uri:
Netlify.env.get('APOLLO_STUDIO_URL') ??
'https://api.apollographql.com/api/graphql',
cache: new InMemoryCache(),
});

const docQuery = gql`query Doc($graphId: ID!, $hash: SHA256) {
graph(id: $graphId) {
doc(hash: $hash) {
source
const docsQuery = gql`
query CustomChecksExampleDocs($graphId: ID!, $hashes: [SHA256!]!) {
graph(id: $graphId) {
docs(hashes: $hashes) {
hash
source
}
}
}
}`;
`;

const customCheckCallbackMutation = gql`mutation CustomCheckCallback($input: CustomCheckCallbackInput!, $name: String!, $graphId: ID!) {
graph(id: $graphId) {
variant(name: $name) {
customCheckCallback(input: $input) {
__typename
... on CustomCheckResult {
violations {
level
const customCheckCallbackMutation = gql`
mutation CustomCheckCallback(
$input: CustomCheckCallbackInput!
$name: String!
$graphId: ID!
) {
graph(id: $graphId) {
variant(name: $name) {
customCheckCallback(input: $input) {
__typename
... on CustomCheckResult {
violations {
level
message
rule
}
}
... on PermissionError {
message
}
... on TaskError {
message
}
... on ValidationError {
message
rule
}
}
... on PermissionError {
message
}
... on TaskError {
message
}
... on ValidationError {
message
}
}
}
}
}`;
`;

interface Payload {
baseSchema: {
hash: string;
subgraphs?: Array<{ hash: string; name: string }> | null;
};
proposedSchema: {
hash: string;
subgraphs?: Array<{ hash: string; name: string }> | null;
};
checkStep: {
taskId: string;
graphId: string;
graphVariant: string;
workflowId: string;
};
gitContext: {
branch?: string | null;
commit?: string | null;
committer?: string | null;
message?: string | null;
remoteUrl?: string | null;
};
}

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

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

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

if (providedSignature === calculatedSignature) {
const event = JSON.parse(payload);
const event = JSON.parse(payload) as Payload;
console.log(`Handling taskId: ${event.checkStep.taskId}`);
const docResult = await apolloClient.query({
query: docQuery,
const changedSubgraphs = (event.proposedSchema.subgraphs ?? []).filter(
(proposedSubgraph) =>
event.baseSchema.subgraphs?.find(
(baseSubgraph) => baseSubgraph.name === proposedSubgraph.name,
)?.hash !== proposedSubgraph.hash,
);
const hashesToCheck = [
event.proposedSchema.hash,
...changedSubgraphs.map((s) => s.hash),
];
console.log(`fetching: ${hashesToCheck}`);
const docsResult = await apolloClient
.query<{
graph: null | {
docs: null | Array<null | { hash: string; source: string }>;
};
}>({
query: docsQuery,
variables: {
graphId: event.checkStep.graphId,
hashes: hashesToCheck,
},
context: {
headers: {
'Content-Type': 'application/json',
'apollographql-client-name': 'custom-checks-example',
'apollographql-client-version': '0.0.1',
'x-api-key': apiKey,
},
},
})
.catch((err) => {
console.error(err);
return { data: { graph: null } };
});
const supergraphSource = docsResult.data.graph?.docs?.find(
(doc) => doc?.hash === event.proposedSchema.hash,
)?.source;
const violations = (
await Promise.all(
changedSubgraphs.map(async (subgraph) => {
const code = docsResult.data.graph?.docs?.find(
(doc) => doc?.hash === subgraph.hash,
)?.source;
if (typeof code !== 'string') {
return null;
}
const eslingConfig: Linter.Config = {
files: ['*.graphql'],
plugins: {
'@graphql-eslint': graphql as unknown as ESLint.Plugin,
},
rules: graphql.flatConfigs['schema-recommended']
.rules as unknown as Linter.RulesRecord,
languageOptions: {
parser: graphql,
parserOptions: {
graphQLConfig: { schema: supergraphSource },
},
},
};
try {
const messages = linter.verify(
code,
eslingConfig,
'schema.graphql',
);
console.log(`eslint messages: ${JSON.stringify(messages)}`);
return messages.map((violation) => {
const startSourceLocationCoordiante = getSourceLocationCoordiante(
code,
violation.line,
violation.column,
);
return {
level:
violation.severity === 2
? ('ERROR' as const)
: ('WARNING' as const),
message: violation.message,
rule: violation.ruleId ?? 'unknown',
sourceLocations: [
{
subgraphName: subgraph.name,
start: startSourceLocationCoordiante,
end:
typeof violation.endLine === 'number' &&
typeof violation.endColumn === 'number'
? getSourceLocationCoordiante(
code,
violation.endLine,
violation.endColumn,
)
: startSourceLocationCoordiante,
},
],
};
});
} catch (err) {
console.log(`Error: ${err}`);
return null;
}
}),
)
).flat();

console.log(
'variables',
JSON.stringify({
graphId: event.checkStep.graphId,
name: event.checkStep.graphVariant,
input: {
taskId: event.checkStep.taskId,
workflowId: event.checkStep.workflowId,
status: violations.some(
(violation) => violation === null || violation.level === 'ERROR',
)
? 'FAILURE'
: 'SUCCESS',
violations: violations.filter((v): v is NonNullable<typeof v> => !!v),
},
}),
);
const callbackResult = await apolloClient.mutate({
mutation: customCheckCallbackMutation,
errorPolicy: 'all',
variables: {
graphId: event.checkStep.graphId,
// supergraph hash
hash: event.proposedSchema.hash,
name: event.checkStep.graphVariant,
input: {
taskId: event.checkStep.taskId,
workflowId: event.checkStep.workflowId,
status: violations.some(
(violation) => violation === null || violation.level === 'ERROR',
)
? 'FAILURE'
: 'SUCCESS',
violations: violations.filter((v): v is NonNullable<typeof v> => !!v),
},
},
context: {
headers: {
"Content-Type": "application/json",
"apollographql-client-name": "custom-checks-example",
"apollographql-client-version": "0.0.1",
"x-api-key": apiKey
}
}
});
const code = docResult.data.graph.doc.source

// @ts-ignore
const messages = linter.verify(code, {
files: ['*.graphql'],
plugins: {
'@graphql-eslint': { rules: graphql.rules },
},
languageOptions: {
parser: graphql,
parserOptions: {
graphQLConfig: { schema: code },
'Content-Type': 'application/json',
'apollographql-client-name': 'custom-checks-example',
'apollographql-client-version': '0.0.1',
'x-api-key': apiKey,
},
},
rules: graphql.flatConfigs['schema-recommended'].rules,
}, 'schema.graphql');

console.log(`eslint messages: ${JSON.stringify(messages)}`);

const violations = messages.map(violation => ({
// Fail check if a naming convention is violated
level: violation.ruleId === '@graphql-eslint/naming-convention' ? 'ERROR' : 'WARNING',
message: violation.message,
rule: violation.ruleId ?? 'unknown',
sourceLocations: {
start: {
byteOffset: 0,
line: violation.line,
column: violation.column,
},
end: {
byteOffset: 0,
line: violation.endLine,
column: violation.endColumn,
}
}
}));

const callbackResult = await apolloClient.mutate({
mutation: customCheckCallbackMutation,
variables: {
graphId: event.checkStep.graphId,
name: event.checkStep.graphVariant,
input: {
taskId: event.checkStep.taskId,
workflowId: event.checkStep.workflowId,
status: violations.find(violation => violation.level === 'ERROR') !== undefined ? 'FAILURE' : 'SUCCESS',
violations: violations,
}
},
context: {
headers: {
"Content-Type": "application/json",
"apollographql-client-name": "custom-checks-example",
"apollographql-client-version": "0.0.1",
"x-api-key": apiKey
}
}
});
console.log(JSON.stringify(`Callback results: ${JSON.stringify(callbackResult)}`));
});
console.log(
JSON.stringify(`Callback results: ${JSON.stringify(callbackResult)}`),
);
return new Response('OK', { status: 200 });
} else {
return new Response('Signature is invalid', { status: 403 });
}
};

export const config: Config = {
path: '/custom-lint'
path: '/custom-lint',
};

0 comments on commit f7d2e70

Please sign in to comment.