feat(core): add nx.json, workspace.json, and project.json JSON schemas (#10228)

* feat(core): add nx.json, workspace.json, and project.json JSON schemas

ISSUES CLOSED: #8574, #2299

* fix(core): add ajv to test generated config files based on JSON schema

* fix(core): only add $schema to project.json if it is standalone and in create mode

* feat(core): add migration to add json schema to config files for 14.2.0

* fix(core): adjust schemas

* chore(core): adjust tests across repo to adhere to JSON schema if generated

* fix(core): construct the json schema object instead of using a boolean flag

* chore(core): add ajv tests for workspacejson and nxjson

* chore(core): remove unnecessary standalone check
This commit is contained in:
Chau Tran 2022-05-10 15:05:26 -05:00 committed by GitHub
parent fecbb81120
commit 512237caf7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 563 additions and 19 deletions

View File

@ -113,6 +113,7 @@
"@xstate/immer": "^0.2.0",
"@xstate/inspect": "^0.5.1",
"@xstate/react": "^1.6.3",
"ajv": "^8.11.0",
"angular": "1.8.0",
"autoprefixer": "^10.2.5",
"babel-jest": "27.5.1",
@ -305,4 +306,4 @@
"**/xmlhttprequest-ssl": "~1.6.2",
"minimist": "^1.2.6"
}
}
}

View File

@ -331,6 +331,7 @@ describe('e2e migrator', () => {
expect(angularJson.projects['app1-e2e']).toBe('apps/app1-e2e');
const e2eProject = readProjectConfiguration(tree, 'app1-e2e');
expect(e2eProject).toStrictEqual({
$schema: '../../node_modules/nx/schemas/project-schema.json',
root: 'apps/app1-e2e',
sourceRoot: 'apps/app1-e2e/src',
projectType: 'application',
@ -457,6 +458,7 @@ describe('e2e migrator', () => {
expect(angularJson.projects['app1-e2e']).toBe('apps/app1-e2e');
const e2eProject = readProjectConfiguration(tree, 'app1-e2e');
expect(e2eProject).toStrictEqual({
$schema: '../../node_modules/nx/schemas/project-schema.json',
root: 'apps/app1-e2e',
sourceRoot: 'apps/app1-e2e/src',
projectType: 'application',
@ -521,6 +523,7 @@ describe('e2e migrator', () => {
const e2eProject = readProjectConfiguration(tree, 'app1-e2e');
expect(e2eProject).toStrictEqual({
$schema: '../../node_modules/nx/schemas/project-schema.json',
root: 'apps/app1-e2e',
sourceRoot: 'apps/app1-e2e/src',
projectType: 'application',
@ -573,6 +576,7 @@ describe('e2e migrator', () => {
const e2eProject = readProjectConfiguration(tree, 'app1-e2e');
expect(e2eProject).toStrictEqual({
$schema: '../../node_modules/nx/schemas/project-schema.json',
root: 'apps/app1-e2e',
sourceRoot: 'apps/app1-e2e/src',
projectType: 'application',

View File

@ -27,6 +27,9 @@ describe('set-build-libs-from-source migration', () => {
};
addProjectConfiguration(tree, 'app1', project);
// add $schema to projectConfig manually
project['$schema'] = '../../node_modules/nx/schemas/project-schema.json';
await migration(tree);
const resultingProject = readProjectConfiguration(tree, 'app1');

View File

@ -58,6 +58,7 @@ describe('React default development configuration', () => {
const config = readProjectConfiguration(tree, 'example');
expect(config).toEqual({
$schema: '../../node_modules/nx/schemas/project-schema.json',
root: 'apps/example',
projectType: 'application',
});

View File

@ -5,6 +5,12 @@
"version": "14.0.6",
"description": "Remove root property from project.json files",
"factory": "./src/migrations/update-14-0-6/remove-roots"
},
"14-2-0-add-json-schema": {
"cli": "nx",
"version": "14.2.0-beta.0",
"description": "Add JSON Schema to Nx configuration files",
"factory": "./src/migrations/update-14-2-0/add-json-schema"
}
}
}

View File

@ -1,7 +1,7 @@
import path = require('path');
import json = require('./migrations.json');
describe('Node migrations', () => {
describe('Nx migrations', () => {
it('should have valid paths', () => {
Object.values(json.generators).forEach((m) => {
expect(() =>

View File

@ -0,0 +1,127 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://nx.dev/core-concepts/configuration#nxjson",
"title": "JSON schema for Nx configuration",
"type": "object",
"properties": {
"implicitDependencies": {
"type": "object",
"description": "Map of files to projects that implicitly depend on them."
},
"affected": {
"type": "object",
"description": "Default options for `nx affected`.",
"properties": {
"defaultBase": {
"type": "string",
"description": "Default based branch used by affected commands."
}
},
"additionalProperties": false
},
"npmScope": {
"type": "string",
"description": "NPM Scope that the workspace uses."
},
"tasksRunnerOptions": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/tasksRunnerOptions"
}
},
"targetDependencies": {
"type": "object",
"description": "Dependencies between different target names across all projects.",
"additionalProperties": {
"$ref": "#/definitions/targetDependencyConfig"
}
},
"workspaceLayout": {
"type": "object",
"description": "Where new apps + libs should be placed.",
"properties": {
"libsDir": {
"type": "string",
"description": "Default folder name for libs."
},
"appsDir": {
"type": "string",
"description": "Default folder name for apps."
}
},
"additionalProperties": false
},
"cli": {
"$ref": "#/definitions/cliOptions"
},
"generators": {
"$ref": "#/definitions/generatorOptions"
},
"plugins": {
"type": "array",
"description": "Plugins for extending the project graph.",
"items": {
"type": "string"
}
},
"defaultProject": {
"type": "string",
"description": "Default project. When project isn't provided, the default project will be used."
}
},
"definitions": {
"cliOptions": {
"type": "object",
"description": "Default generator collection.",
"properties": {
"packageManager": {
"type": "string",
"description": "The default package manager to use.",
"enum": ["yarn", "pnpm", "npm"]
},
"defaultCollection": {
"type": "string",
"description": "The default schematics collection to use."
}
}
},
"generatorOptions": {
"type": "object",
"description": "List of default values used by generators."
},
"tasksRunnerOptions": {
"type": "object",
"description": "Available Task Runners.",
"properties": {
"runner": {
"type": "string",
"description": "Path to resolve the runner."
},
"options": {
"type": "object",
"description": "Default options for the runner."
}
},
"additionalProperties": false
},
"targetDependencyConfig": {
"type": "array",
"description": "Target dependency.",
"items": {
"type": "object",
"properties": {
"projects": {
"type": "string",
"description": "The projects that the targets belong to.",
"enum": ["self", "dependencies"]
},
"target": {
"type": "string",
"description": "The name of the target."
}
},
"additionalProperties": false
}
}
}
}

View File

@ -0,0 +1,68 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://nx.dev/project-schema",
"title": "JSON schema for Nx projects",
"type": "object",
"properties": {
"targets": {
"type": "object",
"description": "Configures all the targets which define what tasks you can run against the project",
"additionalProperties": {
"type": "object",
"properties": {
"executor": {
"description": "The function that Nx will invoke when you run this target",
"type": "string"
},
"options": {
"type": "object"
},
"outputs": {
"type": "array",
"items": {
"type": "string"
}
},
"configurations": {
"type": "object",
"description": "provides extra sets of values that will be merged into the options map",
"additionalProperties": {
"type": "object"
}
},
"dependsOn": {
"type": "array",
"description": "Target dependency.",
"items": {
"type": "object",
"properties": {
"projects": {
"type": "string",
"description": "The projects that the targets belong to.",
"enum": ["self", "dependencies"]
},
"target": {
"type": "string",
"description": "The name of the target."
}
},
"additionalProperties": false
}
}
}
}
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"implicitDependencies": {
"type": "array",
"items": {
"type": "string"
}
}
}
}

View File

@ -0,0 +1,134 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://nx.dev",
"title": "JSON schema for Nx workspaces",
"type": "object",
"properties": {
"version": {
"type": "number",
"enum": [1, 2]
}
},
"allOf": [
{
"if": {
"properties": { "version": { "const": 2 } },
"required": ["version"]
},
"then": {
"properties": {
"projects": {
"type": "object",
"additionalProperties": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"targets": {
"type": "object",
"description": "Configures all the targets which define what tasks you can run against the project",
"additionalProperties": {
"type": "object",
"properties": {
"executor": {
"description": "The function that Nx will invoke when you run this target",
"type": "string"
},
"options": {
"type": "object"
},
"outputs": {
"type": "array",
"items": {
"type": "string"
}
},
"configurations": {
"type": "object",
"description": "provides extra sets of values that will be merged into the options map",
"additionalProperties": {
"type": "object"
}
},
"dependsOn": {
"type": "array",
"description": "Target dependency.",
"items": {
"type": "object",
"properties": {
"projects": {
"type": "string",
"description": "The projects that the targets belong to.",
"enum": ["self", "dependencies"]
},
"target": {
"type": "string",
"description": "The name of the target."
}
},
"additionalProperties": false
}
}
}
}
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"implicitDependencies": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
]
}
}
}
},
"else": {
"properties": {
"projects": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"architect": {
"type": "object",
"description": "Configures all the targets which define what tasks you can run against the project",
"additionalProperties": {
"type": "object",
"properties": {
"builder": {
"description": "The function that Nx will invoke when you run this architect",
"type": "string"
},
"options": {
"type": "object"
},
"configurations": {
"type": "object",
"description": "provides extra sets of values that will be merged into the options map",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
}
}
}
]
}

View File

@ -1,3 +1,4 @@
import Ajv from 'ajv';
import { Tree } from '../tree';
import { ProjectConfiguration } from '../../config/workspace-json-project-json';
@ -15,6 +16,8 @@ import {
WorkspaceConfiguration,
} from './project-configuration';
import * as projectSchema from '../../../schemas/project-schema.json';
type ProjectConfigurationV1 = Pick<
ProjectConfiguration,
'root' | 'sourceRoot'
@ -336,9 +339,33 @@ describe('project configuration', () => {
addProjectConfiguration(tree, 'test', baseTestProjectConfigV2, true);
addProjectConfiguration(tree, 'test2', baseTestProjectConfigV2, false);
const configurations = getProjects(tree);
expect(configurations.get('test')).toEqual(baseTestProjectConfigV2);
expect(configurations.get('test')).toEqual({
$schema: '../../node_modules/nx/schemas/project-schema.json',
...baseTestProjectConfigV2,
});
expect(configurations.get('test2')).toEqual(baseTestProjectConfigV2);
});
describe('JSON schema', () => {
it('should have JSON $schema in project configuration for standalone projects', () => {
addProjectConfiguration(tree, 'test', baseTestProjectConfigV2, true);
const projectJson = readJson(tree, 'libs/test/project.json');
expect(projectJson['$schema']).toBeTruthy();
expect(projectJson['$schema']).toEqual(
'../../node_modules/nx/schemas/project-schema.json'
);
});
it('should match project configuration with JSON $schema', () => {
const ajv = new Ajv();
const validate = ajv.compile(projectSchema);
addProjectConfiguration(tree, 'test', baseTestProjectConfigV2, true);
const projectJson = readJson(tree, 'libs/test/project.json');
expect(validate(projectJson)).toEqual(true);
});
});
});
describe('updateWorkspaceConfiguration', () => {
@ -515,7 +542,10 @@ describe('project configuration', () => {
const configurations = getProjects(tree);
expect(configurations.get('test')).toEqual(baseTestProjectConfigV2);
expect(configurations.get('test')).toEqual({
$schema: '../../node_modules/nx/schemas/project-schema.json',
...baseTestProjectConfigV2,
});
expect(configurations.get('test2')).toEqual(baseTestProjectConfigV2);
});
});

View File

@ -1,3 +1,10 @@
import { basename, dirname, join, relative } from 'path';
import type { NxJsonConfiguration } from '../../config/nx-json';
import {
ProjectConfiguration,
RawWorkspaceJsonConfiguration,
WorkspaceJsonConfiguration,
} from '../../config/workspace-json-project-json';
import {
buildWorkspaceConfigurationFromGlobs,
deduplicateProjectFiles,
@ -5,18 +12,11 @@ import {
reformattedWorkspaceJsonOrNull,
toNewFormat,
} from '../../config/workspaces';
import { basename, dirname, relative } from 'path';
import { readJson, updateJson, writeJson } from './json';
import { joinPathFragments } from '../../utils/path';
import type { Tree } from '../tree';
import type { NxJsonConfiguration } from '../../config/nx-json';
import { joinPathFragments } from '../../utils/path';
import {
ProjectConfiguration,
RawWorkspaceJsonConfiguration,
WorkspaceJsonConfiguration,
} from '../../config/workspace-json-project-json';
import { readJson, updateJson, writeJson } from './json';
export type WorkspaceConfiguration = Omit<
WorkspaceJsonConfiguration,
@ -314,6 +314,16 @@ function setProjectConfiguration(
);
}
export function getRelativeProjectJsonSchemaPath(
tree: Tree,
project: ProjectConfiguration
): string {
return relative(
join(tree.root, project.root),
join(tree.root, 'node_modules/nx/schemas/project-schema.json')
);
}
function addProjectToWorkspaceJson(
tree: Tree,
projectName: string,
@ -342,6 +352,10 @@ function addProjectToWorkspaceJson(
(mode === 'create' && standalone) || !workspaceConfigPath
? joinPathFragments(project.root, 'project.json')
: getProjectFileLocation(tree, projectName);
const jsonSchema =
configFile && mode === 'create'
? { $schema: getRelativeProjectJsonSchemaPath(tree, project) }
: {};
if (configFile) {
if (mode === 'delete') {
@ -352,8 +366,13 @@ function addProjectToWorkspaceJson(
if (workspaceConfigPath && mode === 'create') {
workspaceJson.projects[projectName] = project.root;
}
// update the project.json file
writeJson(tree, configFile, { ...project, root: undefined });
writeJson(tree, configFile, {
...jsonSchema,
...project,
root: undefined,
});
}
} else if (mode === 'delete') {
delete workspaceJson.projects[projectName];

View File

@ -0,0 +1,60 @@
import { createTreeWithEmptyWorkspace } from '../../generators/testing-utils/create-tree-with-empty-workspace';
import type { Tree } from '../../generators/tree';
import { readJson } from '../../generators/utils/json';
import { addProjectConfiguration } from '../../generators/utils/project-configuration';
import addJsonSchema from './add-json-schema';
describe('add-json-schema >', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace(2);
});
it('should update nx.json $schema', async () => {
const nxJson = readJson(tree, 'nx.json');
delete nxJson['$schema'];
await addJsonSchema(tree);
expect(readJson(tree, 'nx.json')['$schema']).toEqual(
'./node_modules/nx/schemas/nx-schema.json'
);
});
it('should update workspace.json $schema', async () => {
const workspaceJson = readJson(tree, 'workspace.json');
delete workspaceJson['$schema'];
await addJsonSchema(tree);
expect(readJson(tree, 'workspace.json')['$schema']).toEqual(
'./node_modules/nx/schemas/workspace-schema.json'
);
});
it('should update project.json $schema', async () => {
addProjectConfiguration(
tree,
'test',
{ root: 'libs/test', sourceRoot: 'libs/test/src', targets: {} },
true
);
addProjectConfiguration(
tree,
'test-two',
{
root: 'libs/nested/test-two',
sourceRoot: 'libs/nested/test-two/src',
targets: {},
},
true
);
await addJsonSchema(tree);
expect(readJson(tree, 'libs/test/project.json')['$schema']).toEqual(
'../../node_modules/nx/schemas/project-schema.json'
);
expect(
readJson(tree, 'libs/nested/test-two/project.json')['$schema']
).toEqual('../../../node_modules/nx/schemas/project-schema.json');
});
});

View File

@ -0,0 +1,46 @@
import { ProjectConfiguration } from '../../config/workspace-json-project-json';
import type { Tree } from '../../generators/tree';
import { updateJson } from '../../generators/utils/json';
import {
getProjects,
getRelativeProjectJsonSchemaPath,
updateProjectConfiguration,
} from '../../generators/utils/project-configuration';
export default async function (tree: Tree) {
// update nx.json $schema
const isNxJsonExist = tree.exists('nx.json');
if (isNxJsonExist) {
updateJson(tree, 'nx.json', (json) => {
if (!json['$schema']) {
json['$schema'] = './node_modules/nx/schemas/nx-schema.json';
}
return json;
});
}
// update workspace.json $schema
const isWorkspaceJsonExist = tree.exists('workspace.json');
if (isWorkspaceJsonExist) {
updateJson(tree, 'workspace.json', (json) => {
if (!json['$schema']) {
json['$schema'] = './node_modules/nx/schemas/workspace-schema.json';
}
return json;
});
}
// update projects $schema
for (const [projName, projConfig] of getProjects(tree)) {
if (projConfig['$schema']) continue;
const relativeProjectJsonSchemaPath = getRelativeProjectJsonSchemaPath(
tree,
projConfig
);
updateProjectConfiguration(tree, projName, {
$schema: relativeProjectJsonSchemaPath,
...projConfig,
} as ProjectConfiguration);
}
}

View File

@ -62,6 +62,7 @@ describe('React default development configuration', () => {
const config = readProjectConfiguration(tree, 'example');
expect(config).toEqual({
$schema: '../../node_modules/nx/schemas/project-schema.json',
root: 'apps/example',
projectType: 'application',
});

View File

@ -1,4 +1,4 @@
import { Tree } from '@nrwl/devkit';
import { readJson, Tree } from '@nrwl/devkit';
import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing';
import { moveGenerator } from './move';
import { libraryGenerator } from '../library/library';
@ -6,7 +6,7 @@ import { libraryGenerator } from '../library/library';
describe('move', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
tree = createTreeWithEmptyWorkspace(2);
});
it('should update jest config when moving down directories', async () => {
@ -44,4 +44,25 @@ describe('move', () => {
"coverageDirectory: '../../coverage/libs/my-lib-new'"
);
});
it('should update $schema path when move', async () => {
await libraryGenerator(tree, { name: 'my-lib', standaloneConfig: true });
let projectJson = readJson(tree, 'libs/my-lib/project.json');
expect(projectJson['$schema']).toEqual(
'../../node_modules/nx/schemas/project-schema.json'
);
await moveGenerator(tree, {
projectName: 'my-lib',
importPath: '@proj/shared-mylib',
updateImportPath: true,
destination: 'shared/my-lib-new',
});
projectJson = readJson(tree, 'libs/shared/my-lib-new/project.json');
expect(projectJson['$schema']).toEqual(
'../../../node_modules/nx/schemas/project-schema.json'
);
});
});

View File

@ -71,6 +71,7 @@ Object {
exports[`new should generate an empty nx.json 1`] = `
Object {
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"affected": Object {
"defaultBase": "main",
},
@ -111,6 +112,7 @@ Object {
exports[`new should generate an empty workspace.json 1`] = `
Object {
"$schema": "./node_modules/nx/schemas/workspace-schema.json",
"projects": Object {},
"version": 2,
}

View File

@ -1,4 +1,5 @@
{
"$schema": "./node_modules/nx/schemas/workspace-schema.json",
"version": 2,
"projects": {}
}

View File

@ -1,4 +1,5 @@
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"npmScope": "<%= npmScope %>",
"affected": {
"defaultBase": "<%= defaultBase %>"

View File

@ -1,8 +1,11 @@
import { readJson } from '@nrwl/devkit';
import type { Tree, NxJsonConfiguration } from '@nrwl/devkit';
import Ajv from 'ajv';
import { workspaceGenerator } from './workspace';
import { createTree } from '@nrwl/devkit/testing';
import { Preset } from '../utils/presets';
import * as nxSchema from '../../../../nx/schemas/nx-schema.json';
import * as workspaceSchema from '../../../../nx/schemas/workspace-schema.json';
describe('@nrwl/workspace:workspace', () => {
let tree: Tree;
@ -25,7 +28,9 @@ describe('@nrwl/workspace:workspace', () => {
expect(tree.exists('/proj/.prettierignore')).toBe(true);
});
it('should create nx.json', async () => {
it('should create nx.json and workspace.json', async () => {
const ajv = new Ajv();
await workspaceGenerator(tree, {
name: 'proj',
directory: 'proj',
@ -35,6 +40,7 @@ describe('@nrwl/workspace:workspace', () => {
});
const nxJson = readJson<NxJsonConfiguration>(tree, '/proj/nx.json');
expect(nxJson).toEqual({
$schema: './node_modules/nx/schemas/nx-schema.json',
npmScope: 'proj',
affected: {
defaultBase: 'main',
@ -66,6 +72,17 @@ describe('@nrwl/workspace:workspace', () => {
],
},
});
const validateNxJson = ajv.compile(nxSchema);
expect(validateNxJson(nxJson)).toEqual(true);
const workspaceJson = readJson(tree, '/proj/workspace.json');
expect(workspaceJson).toEqual({
$schema: './node_modules/nx/schemas/workspace-schema.json',
version: 2,
projects: {},
});
const validateWorkspaceJson = ajv.compile(workspaceSchema);
expect(validateWorkspaceJson(workspaceJson)).toEqual(true);
});
it('should create a prettierrc file', async () => {

View File

@ -43,6 +43,7 @@ describe('add `defaultBase` in nx.json', () => {
const projects = Object.fromEntries(getProjects(tree).entries());
expect(projects).toEqual({
'tsc-project': {
$schema: '../../node_modules/nx/schemas/project-schema.json',
root: 'projects/tsc-project',
targets: {
build: {
@ -54,6 +55,7 @@ describe('add `defaultBase` in nx.json', () => {
},
},
'other-project': {
$schema: '../../node_modules/nx/schemas/project-schema.json',
root: 'projects/other-project',
targets: {
build: {

View File

@ -5878,7 +5878,7 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.12.3, ajv@^6.12.4, ajv
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
ajv@^8.0.0, ajv@^8.8.0:
ajv@^8.0.0, ajv@^8.11.0, ajv@^8.8.0:
version "8.11.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.11.0.tgz#977e91dd96ca669f54a11e23e378e33b884a565f"
integrity sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==