Skip to content

Commit

Permalink
Add ability to create directory config files (#40)
Browse files Browse the repository at this point in the history
## Summary:
Users will be able to add files with the name `*graphql-flow.config.js` with a subset of the config fields (`options`, `excludes`) in order to have different behavior in that directory and its subdirectories.
Another field, `extends`, takes the path of another sub-config (or the root config) and will use the extended config as a base to add or replace option fields and add new excludes.
This is the first step in adding the ability to export robust flow enums, which we want to limit as to not disrupt other teams.

Issue: LP-11796

## Test plan:
Thoroughly unit tested. Add `graphql-flow.config.js` with different options to any subdirectory and re-run `graphql-flow`.

Author: nedredmond

Reviewers: jaredly

Required Reviewers:

Approved By: jaredly

Checks: ✅ Lint & Test (ubuntu-latest, 16.x)

Pull Request URL: #40
  • Loading branch information
nedredmond authored May 25, 2022
1 parent cbb165c commit 5078624
Show file tree
Hide file tree
Showing 7 changed files with 295 additions and 27 deletions.
5 changes: 5 additions & 0 deletions .changeset/slow-moles-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@khanacademy/graphql-flow': minor
---

Users can add files with the name ending in `graphql-flow.config.js` with a subset of the config fields (`options`, `excludes`) in order to have more granular control of the behavior. Another field, `extends`, takes the path of another config file to use as a base and extends/overrides fields. If no `extends` is provided, the file completely overwrites any other config files (as far as `options` and `excludes`).
20 changes: 20 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ Write a config file, with the following options:
}
```

Optionally add subconfig files to subdirectories for granular control of behavior, with the following options:

```json
{
// Note that this file must be named, or end with, "graphql-flow.config.json"
// I.e., "my-service.graphql.config.json" would also work.
// These files will affect the directory in which they are located and all subdirectories, unless overridden by a deeper subconfig.

// Optionally add the path of another config file. Can be the root config (provided when running the script) or any other subconfig to merge options.
// If a chain of extends are provided, will resolve in order. Be sure not to extend in a circle-- currently, this will just cause a stack overflow error.
// Cannot currently override `schemaFilePath`.
"extends": "./another/config/from/root.config.json",
// Can extend or override `excludes` and `options`.
"excludes": ["\\bsome-thing", "_test.jsx?$"],
"options": {
...
}
}
```

Then run from the CLI, like so:

```bash
Expand Down
94 changes: 94 additions & 0 deletions src/cli/__test__/config.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {loadDirConfigFiles} from '../config';

import fs from 'fs';
jest.mock('fs');

const filesResponse = `javascript/discussion-package/components/graphql-flow.config.json
javascript/discussion-package/graphql-flow.config.json
`;
const rootConfigPath = './dev/graphql-flow/config.json';
const mockRootConfig = {
path: rootConfigPath,
config: {
options: {
splitTypes: true,
readOnlyArray: false,
},
excludes: ['this one', 'that one'],
schemaFilePath: 'this/is/a/path.graphql',
dumpOperations: '',
},
};
const firstFileFixture = {
extends: 'javascript/discussion-package/graphql-flow.config.json',
options: {
splitTypes: false,
},
excludes: ['that one'],
};
const secondFileFixture = {
extends: rootConfigPath,
options: {
readOnlyArray: true,
},
excludes: ['another one'],
};

describe('loading subconfigs', () => {
it('should properly extend', () => {
// eslint-disable-next-line flowtype-errors/uncovered
fs.readFileSync
.mockReturnValueOnce(JSON.stringify(firstFileFixture))
.mockReturnValueOnce(JSON.stringify(secondFileFixture));

const dirConfigMap = loadDirConfigFiles(filesResponse, mockRootConfig);

const paths = Object.keys(dirConfigMap);
const subConfig = dirConfigMap[paths[0]];
const deeperConfig = dirConfigMap[paths[1]];

expect(paths).toHaveLength(2);

expect(subConfig.options.splitTypes).toBe(true);
expect(subConfig.options.readOnlyArray).toBe(true);
expect(subConfig.excludes.length).toBe(3);

expect(deeperConfig.options.splitTypes).toBe(false);
expect(subConfig.options.readOnlyArray).toBe(true);
expect(subConfig.excludes.length).toBe(3);
});
it('should properly overwrite', () => {
// eslint-disable-next-line flowtype-errors/uncovered
fs.readFileSync
.mockReturnValueOnce(
JSON.stringify({
options: {},
excludes: ['some other one'],
}),
)
.mockReturnValueOnce(
JSON.stringify({
options: {
splitTypes: false,
},
excludes: ['a completely different one one'],
}),
);

const dirConfigMap = loadDirConfigFiles(filesResponse, mockRootConfig);

const paths = Object.keys(dirConfigMap);
const subConfig = dirConfigMap[paths[0]];
const deeperConfig = dirConfigMap[paths[1]];

expect(paths).toHaveLength(2);

expect(subConfig.options.splitTypes).toBe(undefined);
expect(subConfig.options.readOnlyArray).toBe(undefined);
expect(subConfig.excludes.length).toBe(1);

expect(deeperConfig.options.splitTypes).toBe(false);
expect(subConfig.options.readOnlyArray).toBe(undefined);
expect(subConfig.excludes.length).toBe(1);
});
});
19 changes: 19 additions & 0 deletions src/cli/__test__/utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// @flow
import {longestMatchingPath} from '../utils.js';

describe('longestMatchingPath', () => {
const filePath = 'here/is/a/file/path.exe';
const subConfigPaths = [
'not/a/match',
'here/is',
'here/is/a/file/path',
'here/is/a/file/path/that/is/the/longest',
'here/is/a/file/path/longer',
'here/is/a/file', // here's the one we want
'here/is/a',
];
it('returns expected', () => {
const match = longestMatchingPath(filePath, subConfigPaths);
expect(match).toBe('here/is/a/file');
});
});
142 changes: 118 additions & 24 deletions src/cli/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,30 +51,7 @@ export const loadConfigFile = (configFile: string): CliConfig => {
);
}
});
if (data.options) {
const externalOptionsKeys = [
'pragma',
'loosePragma',
'ignorePragma',
'scalars',
'strictNullability',
'regenerateCommand',
'readOnlyArray',
'splitTypes',
'generatedDirectory',
'exportAllObjectTypes',
'typeFileName',
];
Object.keys(data.options).forEach((k) => {
if (!externalOptionsKeys.includes(k)) {
throw new Error(
`Invalid option in config file ${configFile}: ${k}. Allowed options: ${externalOptionsKeys.join(
', ',
)}`,
);
}
});
}
validateOptions(configFile, data.options ?? {});
return {
options: data.options ?? {},
excludes: data.excludes?.map((string) => new RegExp(string)) ?? [],
Expand All @@ -85,6 +62,93 @@ export const loadConfigFile = (configFile: string): CliConfig => {
};
};

/**
* Subdirectory config to extend or overwrite higher-level config.
* @param {string} extends - Path from root; optional field for a config file in a subdirectory. If left blank, config file will overwrite root for directory.
*/
type JSONSubConfig = {
excludes?: Array<string>,
options?: ExternalOptions,
extends?: string,
};

type SubConfig = {
excludes: Array<RegExp>,
options: ExternalOptions,
extends?: string,
};

export const loadSubConfigFile = (configFile: string): SubConfig => {
const jsonData = fs.readFileSync(configFile, 'utf8');
// eslint-disable-next-line flowtype-errors/uncovered
const data: JSONSubConfig = JSON.parse(jsonData);
const toplevelKeys = ['excludes', 'options', 'extends'];
Object.keys(data).forEach((k) => {
if (!toplevelKeys.includes(k)) {
throw new Error(
`Invalid attribute in non-root config file ${configFile}: ${k}. Allowed attributes: ${toplevelKeys.join(
', ',
)}`,
);
}
});
validateOptions(configFile, data.options ?? {});
return {
excludes: data.excludes?.map((string) => new RegExp(string)) ?? [],
options: data.options ?? {},
extends: data.extends ?? '',
};
};

export const loadDirConfigFiles = (
filesResponse: string,
rootConfig: {path: string, config: CliConfig},
): {[dir: string]: SubConfig} => {
const dirConfigMap: {[key: string]: SubConfig} = {};

// TODO: circular extends will cause infinite loop... consider instrumenting code to monitor for loops in the future?
const loadExtendedConfig = (configPath: string): SubConfig => {
let dirConfig = loadSubConfigFile(configPath);
if (dirConfig.extends) {
const isRootConfig = dirConfig.extends === rootConfig.path;
const {options, excludes} = isRootConfig
? rootConfig.config
: addConfig(dirConfig.extends);
dirConfig = extendConfig({options, excludes}, dirConfig);
}
return dirConfig;
};
const addConfig = (configPath) => {
const {dir} = path.parse(configPath);
if (dirConfigMap[dir]) {
return dirConfigMap[dir];
}
dirConfigMap[dir] = loadExtendedConfig(configPath);
return dirConfigMap[dir];
};
const extendConfig = (
toExtend: SubConfig,
current: SubConfig,
): SubConfig => ({
// $FlowFixMe[exponential-spread]
options: {...toExtend.options, ...current.options},
excludes: Array.from(
new Set([...toExtend.excludes, ...current.excludes]),
),
});

filesResponse
.trim()
.split('\n')
.forEach((configPath) => {
const {dir} = path.parse(configPath);
if (dir && !dirConfigMap[dir]) {
dirConfigMap[dir] = loadExtendedConfig(configPath);
}
});
return dirConfigMap;
};

/**
* Loads a .json 'introspection query response', or a .graphql schema definition.
*/
Expand All @@ -110,3 +174,33 @@ export const getSchemas = (schemaFilePath: string): [GraphQLSchema, Schema] => {
return [schemaForValidation, schemaForTypeGeneration];
}
};

const validateOptions = (
configFile: string,
options: ExternalOptions,
): void => {
if (options) {
const externalOptionsKeys = [
'pragma',
'loosePragma',
'ignorePragma',
'scalars',
'strictNullability',
'regenerateCommand',
'readOnlyArray',
'splitTypes',
'generatedDirectory',
'exportAllObjectTypes',
'typeFileName',
];
Object.keys(options).forEach((k) => {
if (!externalOptionsKeys.includes(k)) {
throw new Error(
`Invalid option in config file ${configFile}: ${k}. Allowed options: ${externalOptionsKeys.join(
', ',
)}`,
);
}
});
}
};
28 changes: 25 additions & 3 deletions src/cli/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import {generateTypeFiles, processPragmas} from '../generateTypeFiles';
import {processFiles} from '../parser/parse';
import {resolveDocuments} from '../parser/resolve';
import {getSchemas, loadConfigFile} from './config';
import {loadDirConfigFiles, getSchemas, loadConfigFile} from './config';

import {addTypenameToDocument} from 'apollo-utilities'; // eslint-disable-line flowtype-errors/uncovered

Expand All @@ -15,6 +15,7 @@ import {print} from 'graphql/language/printer';
import {validate} from 'graphql/validation';
import path from 'path';
import {dirname} from 'path';
import {longestMatchingPath} from './utils';

/**
* This CLI tool executes the following steps:
Expand Down Expand Up @@ -59,6 +60,17 @@ Usage: graphql-flow [configFile.json] [filesToCrawl...]`);

const config = loadConfigFile(configFile);

// find file paths ending with "graphql-flow.config.json"
const subConfigsQuery = () =>
execSync('git ls-files "*graphql-flow.config.json"', {
encoding: 'utf8',
cwd: process.cwd(),
});
const subConfigMap = loadDirConfigFiles(subConfigsQuery(), {
config,
path: configFile,
});

const [schemaForValidation, schemaForTypeGeneration] = getSchemas(
config.schemaFilePath,
);
Expand Down Expand Up @@ -116,7 +128,17 @@ const printedOperations: Array<string> = [];

Object.keys(resolved).forEach((k) => {
const {document, raw} = resolved[k];
if (config.excludes.some((rx) => rx.test(raw.loc.path))) {

let fileConfig = config;
const closestConfigPath = longestMatchingPath(
raw.loc.path,
Object.keys(subConfigMap),
); // get longest match in the case of nested subconfigs
if (closestConfigPath) {
fileConfig = subConfigMap[closestConfigPath];
}

if (fileConfig.excludes.some((rx) => rx.test(raw.loc.path))) {
return; // skip
}
const hasNonFragments = document.definitions.some(
Expand All @@ -131,7 +153,7 @@ Object.keys(resolved).forEach((k) => {
printedOperations.push(printed);
}

const processedOptions = processPragmas(config.options, rawSource);
const processedOptions = processPragmas(fileConfig.options, rawSource);
if (!processedOptions) {
return;
}
Expand Down
14 changes: 14 additions & 0 deletions src/cli/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// @flow
import path from 'path';

export const longestMatchingPath = (
filePath: string,
subConfigPaths: string[],
): string => {
const {dir} = path.parse(filePath);
return subConfigPaths.reduce(
(closest: string, key: string) =>
dir.includes(key) && closest.length < key.length ? key : closest,
'',
);
};

0 comments on commit 5078624

Please sign in to comment.