diff --git a/package.json b/package.json index 493e12c07820..3c481bf3af3c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "test:deps": "node scripts/publish/validate_dependencies.js", "test:inspect": "node --inspect --debug-brk tests/runner", "test:packages": "node scripts/run-packages-spec.js", - "build-config-interface": "dtsgen packages/angular-cli/lib/config/schema.json --out packages/angular-cli/lib/config/schema.d.ts", "eslint": "eslint .", "tslint": "tslint \"**/*.ts\" -c tslint.json -e \"**/blueprints/*/files/**/*.ts\" -e \"node_modules/**\" -e \"tmp/**\" -e \"dist/**\"", "lint": "npm-run-all -c eslint tslint" diff --git a/packages/@ngtools/json-schema/package.json b/packages/@ngtools/json-schema/package.json new file mode 100644 index 000000000000..a1bf952ca658 --- /dev/null +++ b/packages/@ngtools/json-schema/package.json @@ -0,0 +1,32 @@ +{ + "name": "@ngtools/json-schema", + "version": "1.2.1", + "description": "Schema validating and reading for configurations, similar to Angular CLI config.", + "main": "./src/index.js", + "typings": "src/index.d.ts", + "license": "MIT", + "keywords": [ + "angular", + "json", + "json-schema", + "schema", + "config" + ], + "repository": { + "type": "git", + "url": "https://github.com/angular/angular-cli.git" + }, + "author": "angular", + "bugs": { + "url": "https://github.com/angular/angular-cli/issues" + }, + "homepage": "https://github.com/angular/angular-cli/tree/master/packages/@ngtools/json-schema", + "engines": { + "node": ">= 4.1.0", + "npm": ">= 3.0.0" + }, + "dependencies": { + }, + "peerDependencies": { + } +} diff --git a/packages/@ngtools/json-schema/src/error.ts b/packages/@ngtools/json-schema/src/error.ts new file mode 100644 index 000000000000..5863a5decd4e --- /dev/null +++ b/packages/@ngtools/json-schema/src/error.ts @@ -0,0 +1,12 @@ + +export class JsonSchemaErrorBase extends Error { + constructor(message?: string) { + super(); + + if (message) { + this.message = message; + } else { + this.message = (this.constructor).name; + } + } +} diff --git a/packages/@ngtools/json-schema/src/index.ts b/packages/@ngtools/json-schema/src/index.ts new file mode 100644 index 000000000000..1021cae0c409 --- /dev/null +++ b/packages/@ngtools/json-schema/src/index.ts @@ -0,0 +1 @@ +export {SchemaClass, SchemaClassFactory} from './schema-class-factory'; diff --git a/packages/@ngtools/json-schema/src/mimetypes.ts b/packages/@ngtools/json-schema/src/mimetypes.ts new file mode 100644 index 000000000000..6c22ccb9fa81 --- /dev/null +++ b/packages/@ngtools/json-schema/src/mimetypes.ts @@ -0,0 +1,34 @@ +import {JsonSchemaErrorBase} from './error'; +import {Serializer, WriterFn} from './serializer'; +import {JsonSerializer} from './serializers/json'; +import {DTsSerializer} from './serializers/dts'; + + +export class UnknownMimetype extends JsonSchemaErrorBase {} + + +export function createSerializerFromMimetype(mimetype: string, + writer: WriterFn, + ...opts: any[]): Serializer { + let Klass: { new (writer: WriterFn, ...args: any[]): Serializer } = null; + switch (mimetype) { + case 'application/json': Klass = JsonSerializer; break; + case 'text/json': Klass = JsonSerializer; break; + case 'text/x.typescript': Klass = DTsSerializer; break; + case 'text/x.dts': Klass = DTsSerializer; break; + + default: throw new UnknownMimetype(); + } + + return new Klass(writer, ...opts); + +} + + +declare module './serializer' { + namespace Serializer { + export let fromMimetype: typeof createSerializerFromMimetype; + } +} + +Serializer.fromMimetype = createSerializerFromMimetype; diff --git a/packages/@ngtools/json-schema/src/node.ts b/packages/@ngtools/json-schema/src/node.ts new file mode 100644 index 000000000000..873ac6697503 --- /dev/null +++ b/packages/@ngtools/json-schema/src/node.ts @@ -0,0 +1,42 @@ +import {Serializer} from './serializer'; + + +// A TypeScript Type. This can be used to do `new tsType(value)`. +// `null` implies any type; be careful. +export type TypeScriptType = typeof Number + | typeof Boolean + | typeof String + | typeof Object + | typeof Array + | null; + + +// The most generic interface for a schema node. This is used by the serializers. +export interface SchemaNode { + readonly name: string; + readonly type: string; + readonly tsType: TypeScriptType; + readonly defined: boolean; + readonly dirty: boolean; + readonly frozen: boolean; + readonly readOnly: boolean; + readonly defaultValue: any | null; + readonly required: boolean; + readonly parent: SchemaNode | null; + + // Schema related properties. + readonly description: string | null; + + // Object-only properties. `null` for everything else. + readonly children: { [key: string]: SchemaNode } | null; + + // Array-only properties. `null` for everything else. + readonly items: SchemaNode[] | null; + readonly itemPrototype: SchemaNode | null; + + // Mutable properties. + value: any; + + // Serialization. + serialize(serializer: Serializer): void; +} diff --git a/packages/angular-cli/models/json-schema/schema-class-factory.ts b/packages/@ngtools/json-schema/src/schema-class-factory.ts similarity index 96% rename from packages/angular-cli/models/json-schema/schema-class-factory.ts rename to packages/@ngtools/json-schema/src/schema-class-factory.ts index 9af3b537031d..35d4e98a6611 100644 --- a/packages/angular-cli/models/json-schema/schema-class-factory.ts +++ b/packages/@ngtools/json-schema/src/schema-class-factory.ts @@ -1,8 +1,11 @@ -import {NgToolkitError} from '../error'; import {Serializer} from './serializer'; import {RootSchemaTreeNode, SchemaTreeNode} from './schema-tree'; +import {JsonSchemaErrorBase} from './error'; -export class InvalidJsonPath extends NgToolkitError {} +import './mimetypes'; + + +export class InvalidJsonPath extends JsonSchemaErrorBase {} // The schema tree node property of the SchemaClass. @@ -66,6 +69,9 @@ export interface SchemaClass extends Object { $$defined(path: string): boolean; $$delete(path: string): void; + // Direct access to the schema. + $$schema(): RootSchemaTreeNode; + $$serialize(mimetype?: string): string; } diff --git a/packages/@ngtools/json-schema/src/schema-tree.spec.ts b/packages/@ngtools/json-schema/src/schema-tree.spec.ts new file mode 100644 index 000000000000..46fee6f9815b --- /dev/null +++ b/packages/@ngtools/json-schema/src/schema-tree.spec.ts @@ -0,0 +1,34 @@ +import {readFileSync} from 'fs'; +import {join} from 'path'; + +import {RootSchemaTreeNode} from './schema-tree'; + + +describe('SchemaTreeNode', () => { + +}); + + +describe('OneOfSchemaTreeNode', () => { + const schemaJsonFilePath = join(__dirname, '../tests/schema1.json'); + const schemaJson = JSON.parse(readFileSync(schemaJsonFilePath, 'utf-8')); + const valueJsonFilePath = join(__dirname, '../tests/value1-1.json'); + const valueJson = JSON.parse(readFileSync(valueJsonFilePath, 'utf-8')); + + + it('works', () => { + const proto: any = Object.create(null); + new RootSchemaTreeNode(proto, { + value: valueJson, + schema: schemaJson + }); + + expect(proto.oneOfKey2 instanceof Array).toBe(true); + expect(proto.oneOfKey2.length).toBe(2); + + // Set it to a string, which is valid. + proto.oneOfKey2 = 'hello'; + expect(proto.oneOfKey2 instanceof Array).toBe(false); + }); +}); + diff --git a/packages/angular-cli/models/json-schema/schema-tree.ts b/packages/@ngtools/json-schema/src/schema-tree.ts similarity index 53% rename from packages/angular-cli/models/json-schema/schema-tree.ts rename to packages/@ngtools/json-schema/src/schema-tree.ts index 2b80057b6424..75d1fe2fe814 100644 --- a/packages/angular-cli/models/json-schema/schema-tree.ts +++ b/packages/@ngtools/json-schema/src/schema-tree.ts @@ -1,11 +1,11 @@ -import {NgToolkitError} from '../error'; - +import {JsonSchemaErrorBase} from './error'; import {Serializer} from './serializer'; +import {SchemaNode, TypeScriptType} from './node'; -export class InvalidSchema extends NgToolkitError {} -export class MissingImplementationError extends NgToolkitError {} -export class SettingReadOnlyPropertyError extends NgToolkitError {} +export class InvalidSchema extends JsonSchemaErrorBase {} +export class MissingImplementationError extends JsonSchemaErrorBase {} +export class SettingReadOnlyPropertyError extends JsonSchemaErrorBase {} export interface Schema { @@ -18,7 +18,7 @@ export type TreeNodeConstructorArgument = { parent?: SchemaTreeNode; name?: string; value: T; - forward: SchemaTreeNode; + forward?: SchemaTreeNode; schema: Schema; }; @@ -26,7 +26,7 @@ export type TreeNodeConstructorArgument = { /** * Holds all the information, including the value, of a node in the schema tree. */ -export abstract class SchemaTreeNode { +export abstract class SchemaTreeNode implements SchemaNode { // Hierarchy objects protected _parent: SchemaTreeNode; @@ -51,7 +51,9 @@ export abstract class SchemaTreeNode { this._schema = null; this._value = null; - this._forward.dispose(); + if (this._forward) { + this._forward.dispose(); + } this._forward = null; } @@ -67,22 +69,42 @@ export abstract class SchemaTreeNode { } } + get value(): T { return this.get(); } + abstract get type(): string; + abstract get tsType(): TypeScriptType; abstract destroy(): void; + abstract get defaultValue(): any | null; get name() { return this._name; } get readOnly(): boolean { return this._schema['readOnly']; } + get frozen(): boolean { return true; } + get description() { + return 'description' in this._schema ? this._schema['description'] : null; + } + get required() { + if (!this._parent) { + return false; + } + return this._parent.isChildRequired(this.name); + } + + isChildRequired(name: string) { return false; } + get parent(): SchemaTreeNode { return this._parent; } - get children(): { [key: string]: SchemaTreeNode} { return null; } + get children(): { [key: string]: SchemaTreeNode } | null { return null; } + get items(): SchemaTreeNode[] | null { return null; } + get itemPrototype(): SchemaTreeNode | null { return null; } abstract get(): T; - set(v: T) { + set(v: T, force = false) { if (!this.readOnly) { throw new MissingImplementationError(); } throw new SettingReadOnlyPropertyError(); }; + isCompatible(v: any) { return false; } - abstract serialize(serializer: Serializer, value?: T): void; + abstract serialize(serializer: Serializer): void; protected static _defineProperty(proto: any, treeNode: SchemaTreeNode): void { if (treeNode.readOnly) { @@ -104,14 +126,15 @@ export abstract class SchemaTreeNode { /** Base Class used for Non-Leaves TreeNode. Meaning they can have children. */ export abstract class NonLeafSchemaTreeNode extends SchemaTreeNode { dispose() { - for (const key of Object.keys(this.children)) { + for (const key of Object.keys(this.children || {})) { this.children[key].dispose(); } + for (let item of this.items || []) { + item.dispose(); + } super.dispose(); } - // Non leaves are read-only. - get readOnly() { return true; } get() { if (this.defined) { return this._value; @@ -129,24 +152,8 @@ export abstract class NonLeafSchemaTreeNode extends SchemaTreeNode { protected _createChildProperty(name: string, value: T, forward: SchemaTreeNode, schema: Schema, define = true): SchemaTreeNode { - let type: string; - - if (!schema['oneOf']) { - type = schema['type']; - } else { - let testValue = value || schema['default']; - // Match existing value to one of the schema types - for (let testSchema of schema['oneOf']) { - if ((testSchema['type'] === 'array' && Array.isArray(testValue)) - || typeof testValue === testSchema['type']) { - type = testSchema['type']; - schema = testSchema; - break; - } - } - } - - let Klass: any = null; + let type: string = schema['oneOf'] ? 'oneOf' : schema['type']; + let Klass: { new (arg: TreeNodeConstructorArgument): SchemaTreeNode } = null; switch (type) { case 'object': Klass = ObjectSchemaTreeNode; break; @@ -156,9 +163,10 @@ export abstract class NonLeafSchemaTreeNode extends SchemaTreeNode { case 'number': Klass = NumberSchemaTreeNode; break; case 'integer': Klass = IntegerSchemaTreeNode; break; + case 'oneOf': Klass = OneOfSchemaTreeNode; break; + default: - console.error('Type ' + type + ' not understood by SchemaClassFactory.'); - return null; + throw new InvalidSchema('Type ' + type + ' not understood by SchemaClassFactory.'); } const metaData = new Klass({ parent: this, forward, value, schema, name }); @@ -170,20 +178,92 @@ export abstract class NonLeafSchemaTreeNode extends SchemaTreeNode { } +export class OneOfSchemaTreeNode extends NonLeafSchemaTreeNode { + protected _typesPrototype: SchemaTreeNode[]; + protected _currentTypeHolder: SchemaTreeNode | null; + + constructor(metaData: TreeNodeConstructorArgument) { + super(metaData); + + let { value, forward, schema } = metaData; + this._typesPrototype = schema['oneOf'].map((schema: Object) => { + return this._createChildProperty('', '', forward, schema, false); + }); + + this._currentTypeHolder = null; + this._set(value, true, false); + } + + _set(v: any, init: boolean, force: boolean) { + if (!init && this.readOnly && !force) { + throw new SettingReadOnlyPropertyError(); + } + + // Find the first type prototype that is compatible with the + let proto: SchemaTreeNode = null; + for (let i = 0; i < this._typesPrototype.length; i++) { + const p = this._typesPrototype[i]; + if (p.isCompatible(v)) { + proto = p; + break; + } + } + if (proto == null) { + return; + } + + if (!init) { + this.dirty = true; + } + + this._currentTypeHolder = proto; + this._currentTypeHolder.set(v, true); + } + + set(v: any, force = false) { + return this._set(v, false, force); + } + + get(): any { + return this._currentTypeHolder ? this._currentTypeHolder.get() : null; + } + get defaultValue(): any | null { + return null; + } + + get defined() { return this._currentTypeHolder ? this._currentTypeHolder.defined : false; } + get items() { return this._typesPrototype; } + get type() { return 'oneOf'; } + get tsType(): null { return null; } + + serialize(serializer: Serializer) { serializer.outputOneOf(this); } +} + + /** A Schema Tree Node that represents an object. */ export class ObjectSchemaTreeNode extends NonLeafSchemaTreeNode<{[key: string]: any}> { // The map of all children metadata. protected _children: { [key: string]: SchemaTreeNode }; + protected _frozen: boolean = false; constructor(metaData: TreeNodeConstructorArgument) { super(metaData); - let { value, forward, schema } = metaData; - if (value) { - this._defined = true; + this._set(metaData.value, true, false); + } + + _set(value: any, init: boolean, force: boolean) { + if (!init && this.readOnly && !force) { + throw new SettingReadOnlyPropertyError(); } + + const schema = this._schema; + const forward = this._forward; + + this._defined = !!value; this._children = Object.create(null); this._value = Object.create(null); + this._dirty = this._dirty || !init; if (schema['properties']) { for (const name of Object.keys(schema['properties'])) { @@ -195,11 +275,14 @@ export class ObjectSchemaTreeNode extends NonLeafSchemaTreeNode<{[key: string]: propertySchema); } } else if (!schema['additionalProperties']) { - throw new InvalidSchema(); + throw new InvalidSchema('Schema does not have a properties, but doesnt allow for ' + + 'additional properties.'); } if (!schema['additionalProperties']) { + this._frozen = true; Object.freeze(this._value); + Object.freeze(this._children); } else if (value) { // Set other properties which don't have a schema. for (const key of Object.keys(value)) { @@ -208,27 +291,28 @@ export class ObjectSchemaTreeNode extends NonLeafSchemaTreeNode<{[key: string]: } } } - - Object.freeze(this._children); } - serialize(serializer: Serializer, value = this._value) { - serializer.object(() => { - for (const key of Object.keys(value)) { - if (this._children[key]) { - if (this._children[key].defined) { - serializer.property(key, () => this._children[key].serialize(serializer, value[key])); - } - } else if (this._schema['additionalProperties']) { - // Fallback to direct value output for additional properties - serializer.property(key, () => serializer.outputValue(value[key])); - } - } - }); + set(v: any, force = false) { + return this._set(v, false, force); } - get children() { return this._children; } + get frozen(): boolean { return this._frozen; } + + get children(): { [key: string]: SchemaTreeNode } | null { return this._children; } get type() { return 'object'; } + get tsType() { return Object; } + get defaultValue(): any | null { return null; } + + isCompatible(v: any) { return typeof v == 'object' && v !== null; } + isChildRequired(name: string) { + if (this._schema['required']) { + return this._schema['required'].indexOf(name) != -1; + } + return false; + } + + serialize(serializer: Serializer) { serializer.object(this); } } @@ -236,14 +320,28 @@ export class ObjectSchemaTreeNode extends NonLeafSchemaTreeNode<{[key: string]: export class ArraySchemaTreeNode extends NonLeafSchemaTreeNode> { // The map of all items metadata. protected _items: SchemaTreeNode[]; + protected _itemPrototype: SchemaTreeNode; constructor(metaData: TreeNodeConstructorArgument>) { super(metaData); + this._set(metaData.value, true, false); + + // Keep the item's schema as a schema node. This is important to keep type information. + this._itemPrototype = this._createChildProperty('', null, null, metaData.schema['items']); + } + + _set(value: any, init: boolean, force: boolean) { + const schema = this._schema; + const forward = this._forward; + + this._defined = !!value; + this._value = Object.create(null); + this._dirty = this._dirty || !init; - let { value, forward, schema } = metaData; if (value) { this._defined = true; } else { + this._defined = false; value = []; } this._items = []; @@ -253,26 +351,24 @@ export class ArraySchemaTreeNode extends NonLeafSchemaTreeNode> { this._items[index] = this._createChildProperty( '' + index, value && value[index], - forward && (forward as ArraySchemaTreeNode).children[index], + forward && (forward as ArraySchemaTreeNode).items[index], schema['items'] ); } + } - if (!schema['additionalProperties']) { - Object.freeze(this._value); - } + set(v: any, force = false) { + return this._set(v, false, force); } - get children() { return this._items as {[key: string]: any}; } + isCompatible(v: any) { return Array.isArray(v); } get type() { return 'array'; } + get tsType() { return Array; } + get items(): SchemaTreeNode[] { return this._items; } + get itemPrototype(): SchemaTreeNode { return this._itemPrototype; } + get defaultValue(): any | null { return null; } - serialize(serializer: Serializer, value = this._value) { - serializer.array(() => { - for (let i = 0; i < value.length; i++) { - this._items[i].serialize(serializer, value[i]); - } - }); - } + serialize(serializer: Serializer) { serializer.array(this); } } @@ -314,57 +410,61 @@ export abstract class LeafSchemaTreeNode extends SchemaTreeNode { } return this._value === undefined ? undefined : this.convert(this._value); } - set(v: T) { this.dirty = true; this._value = this.convert(v); } + set(v: T, force = false) { + if (this.readOnly && !force) { + throw new SettingReadOnlyPropertyError(); + } + + this.dirty = true; + this._value = this.convert(v); + } destroy() { this._defined = false; this._value = null; } + get defaultValue(): T { + return 'default' in this._schema ? this._default : null; + } + abstract convert(v: any): T; + abstract isCompatible(v: any): boolean; - serialize(serializer: Serializer, value: T = this.get()) { - if (this.defined) { - serializer.outputValue(value); - } + serialize(serializer: Serializer) { + serializer.outputValue(this); } } /** Basic primitives for JSON Schema. */ class StringSchemaTreeNode extends LeafSchemaTreeNode { - serialize(serializer: Serializer, value: string = this.get()) { - if (this.defined) { - serializer.outputString(value); - } - } + serialize(serializer: Serializer) { serializer.outputString(this); } + isCompatible(v: any) { return typeof v == 'string' || v instanceof String; } convert(v: any) { return v === undefined ? undefined : '' + v; } get type() { return 'string'; } + get tsType() { return String; } } class BooleanSchemaTreeNode extends LeafSchemaTreeNode { - serialize(serializer: Serializer, value: boolean = this.get()) { - if (this.defined) { - serializer.outputBoolean(value); - } - } + serialize(serializer: Serializer) { serializer.outputBoolean(this); } + isCompatible(v: any) { return typeof v == 'boolean' || v instanceof Boolean; } convert(v: any) { return v === undefined ? undefined : !!v; } get type() { return 'boolean'; } + get tsType() { return Boolean; } } class NumberSchemaTreeNode extends LeafSchemaTreeNode { - serialize(serializer: Serializer, value: number = this.get()) { - if (this.defined) { - serializer.outputNumber(value); - } - } + serialize(serializer: Serializer) { serializer.outputNumber(this); } + isCompatible(v: any) { return typeof v == 'number' || v instanceof Number; } convert(v: any) { return v === undefined ? undefined : +v; } get type() { return 'number'; } + get tsType() { return Number; } } diff --git a/packages/@ngtools/json-schema/src/serializer.ts b/packages/@ngtools/json-schema/src/serializer.ts new file mode 100644 index 000000000000..ddcb5d7f6c1b --- /dev/null +++ b/packages/@ngtools/json-schema/src/serializer.ts @@ -0,0 +1,26 @@ +import {JsonSchemaErrorBase} from './error'; +import {SchemaNode} from './node'; +export class InvalidStateError extends JsonSchemaErrorBase {} + + +export interface WriterFn { + (str: string): void; +} + +export abstract class Serializer { + abstract start(): void; + abstract end(): void; + + abstract object(node: SchemaNode): void; + abstract property(node: SchemaNode): void; + abstract array(node: SchemaNode): void; + + abstract outputOneOf(node: SchemaNode): void; + + abstract outputString(node: SchemaNode): void; + abstract outputNumber(node: SchemaNode): void; + abstract outputBoolean(node: SchemaNode): void; + + // Fallback when the value does not have metadata. + abstract outputValue(node: SchemaNode): void; +} diff --git a/packages/@ngtools/json-schema/src/serializers/dts.spec.ts b/packages/@ngtools/json-schema/src/serializers/dts.spec.ts new file mode 100644 index 000000000000..cb290690ab1b --- /dev/null +++ b/packages/@ngtools/json-schema/src/serializers/dts.spec.ts @@ -0,0 +1,30 @@ +import * as path from 'path'; +import * as fs from 'fs'; + +import {DTsSerializer} from './dts'; +import {SchemaClassFactory} from '../schema-class-factory'; +import {RootSchemaTreeNode} from '../schema-tree'; + + +describe('DtsSerializer', () => { + const schemaJsonFilePath = path.join(__dirname, '../../tests/schema1.json'); + const schemaJson = JSON.parse(fs.readFileSync(schemaJsonFilePath, 'utf-8')); + const schemaClass = new (SchemaClassFactory(schemaJson))({}); + const schema: RootSchemaTreeNode = schemaClass.$$schema(); + + it('works', () => { + let str = ''; + function writer(s: string) { + str += s; + } + + const serializer = new DTsSerializer(writer, 'HelloWorld'); + + serializer.start(); + schema.serialize(serializer); + serializer.end(); + + // Expect optional properties to be followed by `?` + expect(str).toMatch(/stringKey\?/); + }); +}); diff --git a/packages/@ngtools/json-schema/src/serializers/dts.ts b/packages/@ngtools/json-schema/src/serializers/dts.ts new file mode 100644 index 000000000000..16029edf9ed0 --- /dev/null +++ b/packages/@ngtools/json-schema/src/serializers/dts.ts @@ -0,0 +1,150 @@ +import {SchemaNode} from '../node'; +import {Serializer, WriterFn, InvalidStateError} from '../serializer'; + + +interface DTsSerializerState { + empty?: boolean; + type?: string; + property?: boolean; +} + +export class DTsSerializer implements Serializer { + private _state: DTsSerializerState[] = []; + + constructor(private _writer: WriterFn, private interfaceName?: string, private _indentDelta = 4) { + if (interfaceName) { + _writer(`export interface ${interfaceName} `); + } else { + _writer('export default interface '); + } + } + + private _willOutputValue() { + if (this._state.length > 0) { + const top = this._top(); + top.empty = false; + + if (!top.property) { + this._indent(); + } + } + } + + private _top(): DTsSerializerState { + return this._state[this._state.length - 1] || {}; + } + + private _indent(): string { + if (this._indentDelta == 0) { + return; + } + + let str = '\n'; + let i = this._state.length * this._indentDelta; + while (i--) { + str += ' '; + } + this._writer(str); + } + + start() {} + end() { + if (this._indentDelta) { + this._writer('\n'); + } + } + + object(node: SchemaNode) { + this._willOutputValue(); + + this._writer('{'); + + this._state.push({ empty: true, type: 'object' }); + for (const key of Object.keys(node.children)) { + this.property(node.children[key]); + } + + // Fallback to direct value output for additional properties. + if (!node.frozen) { + this._indent(); + this._writer('[name: string]: any;'); + } + this._state.pop(); + + if (!this._top().empty) { + this._indent(); + } + this._writer('}'); + } + + property(node: SchemaNode) { + this._willOutputValue(); + + if (node.description) { + this._writer('/**'); + this._indent(); + node.description.split('\n').forEach(line => { + this._writer(' * ' + line); + this._indent(); + }); + this._writer(' */'); + this._indent(); + } + + this._writer(node.name); + if (!node.required) { + this._writer('?'); + } + + this._writer(': '); + this._top().property = true; + node.serialize(this); + this._top().property = false; + this._writer(';'); + } + + array(node: SchemaNode) { + this._willOutputValue(); + + node.itemPrototype.serialize(this); + this._writer('[]'); + } + + outputOneOf(node: SchemaNode) { + this._willOutputValue(); + if (!node.items) { + throw new InvalidStateError(); + } + + this._writer('('); + for (let i = 0; i < node.items.length; i++) { + node.items[i].serialize(this); + if (i != node.items.length - 1) { + this._writer(' | '); + } + } + this._writer(')'); + } + + outputValue(node: SchemaNode) { + this._willOutputValue(); + this._writer('any'); + } + + outputString(node: SchemaNode) { + this._willOutputValue(); + this._writer('string'); + } + outputNumber(node: SchemaNode) { + this._willOutputValue(); + this._writer('number'); + } + outputInteger(node: SchemaNode) { + this._willOutputValue(); + this._writer('number'); + } + outputBoolean(node: SchemaNode) { + this._willOutputValue(); + this._writer('boolean'); + } +} diff --git a/packages/@ngtools/json-schema/src/serializers/json.spec.ts b/packages/@ngtools/json-schema/src/serializers/json.spec.ts new file mode 100644 index 000000000000..ef6f36563dfb --- /dev/null +++ b/packages/@ngtools/json-schema/src/serializers/json.spec.ts @@ -0,0 +1,32 @@ +import * as path from 'path'; +import * as fs from 'fs'; + +import {JsonSerializer} from './json'; +import {SchemaClassFactory} from '../schema-class-factory'; +import {RootSchemaTreeNode} from '../schema-tree'; + + +describe('JsonSerializer', () => { + const schemaJsonFilePath = path.join(__dirname, '../../tests/schema1.json'); + const schemaJson = JSON.parse(fs.readFileSync(schemaJsonFilePath, 'utf-8')); + const valueJsonFilePath = path.join(__dirname, '../../tests/value1.json'); + const valueJson = JSON.parse(fs.readFileSync(valueJsonFilePath, 'utf-8')); + + const schemaClass = new (SchemaClassFactory(schemaJson))(valueJson); + const schema: RootSchemaTreeNode = schemaClass.$$schema(); + + it('works', () => { + let str = ''; + function writer(s: string) { + str += s; + } + + const serializer = new JsonSerializer(writer); + + serializer.start(); + schema.serialize(serializer); + serializer.end(); + + expect(JSON.stringify(JSON.parse(str))).toEqual(JSON.stringify(valueJson)); + }); +}); diff --git a/packages/angular-cli/models/json-schema/serializer.ts b/packages/@ngtools/json-schema/src/serializers/json.ts similarity index 50% rename from packages/angular-cli/models/json-schema/serializer.ts rename to packages/@ngtools/json-schema/src/serializers/json.ts index bee8c9645cf3..1ab57575ffd7 100644 --- a/packages/angular-cli/models/json-schema/serializer.ts +++ b/packages/@ngtools/json-schema/src/serializers/json.ts @@ -1,39 +1,5 @@ -import {NgToolkitError} from '../error'; -export class InvalidStateError extends NgToolkitError {} -export class UnknownMimetype extends NgToolkitError {} - - -export interface WriterFn { - (str: string): void; -} - -export abstract class Serializer { - abstract start(): void; - abstract end(): void; - - abstract object(callback: () => void): void; - abstract property(name: string, callback: () => void): void; - abstract array(callback: () => void): void; - - abstract outputString(value: string): void; - abstract outputNumber(value: number): void; - abstract outputBoolean(value: boolean): void; - - // Fallback when the value does not have metadata. - abstract outputValue(value: any): void; - - - static fromMimetype(mimetype: string, writer: WriterFn, ...opts: any[]): Serializer { - let Klass: { new(writer: WriterFn, ...args: any[]): Serializer } = null; - switch (mimetype) { - case 'application/json': Klass = JsonSerializer; break; - - default: throw new UnknownMimetype(); - } - - return new Klass(writer, ...opts); - } -} +import {SchemaNode} from '../node'; +import {Serializer, WriterFn} from '../serializer'; interface JsonSerializerState { @@ -42,7 +8,7 @@ interface JsonSerializerState { property?: boolean; } -class JsonSerializer implements Serializer { +export class JsonSerializer implements Serializer { private _state: JsonSerializerState[] = []; constructor(private _writer: WriterFn, private _indentDelta = 2) {} @@ -87,13 +53,34 @@ class JsonSerializer implements Serializer { } } - object(callback: () => void) { + object(node: SchemaNode) { + if (node.defined == false) { + return; + } + this._willOutputValue(); this._writer('{'); - this._state.push({ empty: true, type: 'object' }); - callback(); + + for (const key of Object.keys(node.children)) { + this.property(node.children[key]); + } + + // Fallback to direct value output for additional properties. + if (!node.frozen) { + for (const key of Object.keys(node.value)) { + if (key in node.children) { + continue; + } + + this._willOutputValue(); + this._writer(JSON.stringify(key)); + this._writer(': '); + this._writer(JSON.stringify(node.value)); + } + } + this._state.pop(); if (!this._top().empty) { @@ -102,22 +89,32 @@ class JsonSerializer implements Serializer { this._writer('}'); } - property(name: string, callback: () => void) { + property(node: SchemaNode) { + if (node.defined == false) { + return; + } + this._willOutputValue(); - this._writer(JSON.stringify(name)); + this._writer(JSON.stringify(node.name)); this._writer(': '); this._top().property = true; - callback(); + node.serialize(this); this._top().property = false; } - array(callback: () => void) { + array(node: SchemaNode) { + if (node.defined == false) { + return; + } + this._willOutputValue(); this._writer('['); this._state.push({ empty: true, type: 'array' }); - callback(); + for (let i = 0; i < node.items.length; i++) { + node.items[i].serialize(this); + } this._state.pop(); if (!this._top().empty) { @@ -126,25 +123,29 @@ class JsonSerializer implements Serializer { this._writer(']'); } - outputValue(value: any) { + outputOneOf(node: SchemaNode) { + this.outputValue(node); + } + + outputValue(node: SchemaNode) { this._willOutputValue(); - this._writer(JSON.stringify(value, null, this._indentDelta)); + this._writer(JSON.stringify(node.value, null, this._indentDelta)); } - outputString(value: string) { + outputString(node: SchemaNode) { this._willOutputValue(); - this._writer(JSON.stringify(value)); + this._writer(JSON.stringify(node.value)); } - outputNumber(value: number) { + outputNumber(node: SchemaNode) { this._willOutputValue(); - this._writer(JSON.stringify(value)); + this._writer(JSON.stringify(node.value)); } - outputInteger(value: number) { + outputInteger(node: SchemaNode) { this._willOutputValue(); - this._writer(JSON.stringify(value)); + this._writer(JSON.stringify(node.value)); } - outputBoolean(value: boolean) { + outputBoolean(node: SchemaNode) { this._willOutputValue(); - this._writer(JSON.stringify(value)); + this._writer(JSON.stringify(node.value)); } } diff --git a/packages/@ngtools/json-schema/tests/schema1.json b/packages/@ngtools/json-schema/tests/schema1.json new file mode 100644 index 000000000000..983feceb8bcd --- /dev/null +++ b/packages/@ngtools/json-schema/tests/schema1.json @@ -0,0 +1,84 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "JsonSchema", + "type": "object", + "properties": { + "requiredKey": { + "type": "number" + }, + "stringKeyDefault": { + "type": "string", + "default": "defaultValue" + }, + "stringKey": { + "type": "string" + }, + "booleanKey": { + "type": "boolean" + }, + "numberKey": { + "type": "number" + }, + "oneOfKey1": { + "oneOf": [ + { "type": "string" }, + { "type": "number" } + ] + }, + "oneOfKey2": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "objectKey1": { + "type": "object", + "properties": { + "stringKey": { + "type": "string" + }, + "objectKey": { + "type": "object", + "properties": { + "stringKey": { + "type": "string" + } + } + } + } + }, + "objectKey2": { + "type": "object", + "properties": { + "stringKey": { + "type": "string", + "default": "default objectKey2.stringKey" + } + }, + "additionalProperties": true + }, + "arrayKey1": { + "type": "array", + "items": { + "type": "object", + "properties": { + "stringKey": { + "type": "string" + } + } + } + }, + "arrayKey2": { + "type": "array", + "items": { + "type": "object", + "properties": { + "stringKey": { + "type": "string" + } + } + } + } + }, + "required": ["requiredKey"] +} \ No newline at end of file diff --git a/packages/@ngtools/json-schema/tests/value1-1.json b/packages/@ngtools/json-schema/tests/value1-1.json new file mode 100644 index 000000000000..4f2d08d38fb4 --- /dev/null +++ b/packages/@ngtools/json-schema/tests/value1-1.json @@ -0,0 +1,8 @@ +{ + "requiredKey": 1, + "arrayKey2": [ + { "stringKey": "value1" }, + { "stringKey": "value2" } + ], + "oneOfKey2": [ "hello", "world" ] +} \ No newline at end of file diff --git a/packages/@ngtools/json-schema/tests/value1.json b/packages/@ngtools/json-schema/tests/value1.json new file mode 100644 index 000000000000..31f049534148 --- /dev/null +++ b/packages/@ngtools/json-schema/tests/value1.json @@ -0,0 +1,7 @@ +{ + "requiredKey": 1, + "arrayKey2": [ + { "stringKey": "value1" }, + { "stringKey": "value2" } + ] +} \ No newline at end of file diff --git a/packages/@ngtools/json-schema/tsconfig.json b/packages/@ngtools/json-schema/tsconfig.json new file mode 100644 index 000000000000..1e9923f52165 --- /dev/null +++ b/packages/@ngtools/json-schema/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "declaration": true, + "experimentalDecorators": true, + "mapRoot": "", + "module": "commonjs", + "moduleResolution": "node", + "noEmitOnError": true, + "noImplicitAny": true, + "outDir": "../../../dist/@ngtools/json-schema", + "rootDir": ".", + "lib": ["es2015", "es6", "dom"], + "target": "es5", + "sourceMap": true, + "sourceRoot": "/", + "baseUrl": "./", + "paths": { + }, + "typeRoots": [ + "../../node_modules/@types" + ], + "types": [ + "jasmine", + "node" + ] + } +} diff --git a/packages/angular-cli/lib/config/.gitignore b/packages/angular-cli/lib/config/.gitignore new file mode 100644 index 000000000000..879ebeae0a5f --- /dev/null +++ b/packages/angular-cli/lib/config/.gitignore @@ -0,0 +1 @@ +schema.d.ts \ No newline at end of file diff --git a/packages/angular-cli/lib/config/schema.d.ts b/packages/angular-cli/lib/config/schema.d.ts deleted file mode 100644 index d323223b5f36..000000000000 --- a/packages/angular-cli/lib/config/schema.d.ts +++ /dev/null @@ -1,79 +0,0 @@ -export interface CliConfig { - /** - * The global configuration of the project. - */ - project?: { - version?: string; - name?: string; - }; - /** - * Properties of the different applications in this project. - */ - apps?: { - root?: string; - outDir?: string; - assets?: string; - deployUrl?: string; - index?: string; - main?: string; - test?: string; - tsconfig?: string; - prefix?: string; - mobile?: boolean; - /** - * Global styles to be included in the build. - */ - styles?: string[]; - /** - * Global scripts to be included in the build. - */ - scripts?: string[]; - /** - * Name and corresponding file for environment config. - */ - environments?: { - [name: string]: any; - }; - }[]; - /** - * Configuration reserved for installed third party addons. - */ - addons?: { - [name: string]: any; - }[]; - /** - * Configuration reserved for installed third party packages. - */ - packages?: { - [name: string]: any; - }[]; - e2e?: { - protractor?: { - config?: string; - }; - }; - test?: { - karma?: { - config?: string; - }; - }; - defaults?: { - styleExt?: string; - prefixInterfaces?: boolean; - poll?: number; - viewEncapsulation?: string; - changeDetection?: string; - inline?: { - style?: boolean; - template?: boolean; - }; - spec?: { - class?: boolean; - component?: boolean; - directive?: boolean; - module?: boolean; - pipe?: boolean; - service?: boolean; - }; - }; -} diff --git a/packages/angular-cli/lib/config/schema.json b/packages/angular-cli/lib/config/schema.json index 3a2a1f02fcc9..c8626d1aac1d 100644 --- a/packages/angular-cli/lib/config/schema.json +++ b/packages/angular-cli/lib/config/schema.json @@ -104,7 +104,8 @@ "type": "string" } }, - "additionalProperties": true + "additionalProperties": true, + "required": ["input"] } ] }, diff --git a/tests/models/config.spec.ts b/packages/angular-cli/models/config/config.spec.ts similarity index 50% rename from tests/models/config.spec.ts rename to packages/angular-cli/models/config/config.spec.ts index ece9ee4f5969..2c6c78eb8744 100644 --- a/tests/models/config.spec.ts +++ b/packages/angular-cli/models/config/config.spec.ts @@ -1,10 +1,8 @@ -import {CliConfig} from 'angular-cli/models/config/config'; +import {CliConfig} from './config'; import * as fs from 'fs'; import * as path from 'path'; import {CliConfig as ConfigInterface} from './spec-schema'; -const expect = require('chai').expect; - describe('Config', () => { let schema = JSON.parse(fs.readFileSync(path.join(__dirname, 'spec-schema.json'), 'utf-8')); @@ -16,17 +14,17 @@ describe('Config', () => { }); const config = cliConfig.config; - expect(config.requiredKey).to.equal(1); - expect(config.stringKey).to.equal('stringValue'); - expect(config.stringKeyDefault).to.equal('defaultValue'); - expect(config.booleanKey).to.equal(undefined); + expect(config.requiredKey).toEqual(1); + expect(config.stringKey).toEqual('stringValue'); + expect(config.stringKeyDefault).toEqual('defaultValue'); + expect(config.booleanKey).toEqual(undefined); - expect(config.arrayKey1).to.equal(undefined); - expect(() => config.arrayKey1[0]).to.throw(); + expect(config.arrayKey1).toEqual(undefined); + expect(() => config.arrayKey1[0]).toThrow(); - expect(config.numberKey).to.equal(undefined); + expect(config.numberKey).toEqual(undefined); config.numberKey = 33; - expect(config.numberKey).to.equal(33); + expect(config.numberKey).toEqual(33); }); describe('Get', () => { @@ -36,9 +34,9 @@ describe('Config', () => { stringKey: 'stringValue' }); - expect(config.get('requiredKey')).to.equal(1); - expect(config.get('stringKey')).to.equal('stringValue'); - expect(config.get('booleanKey')).to.equal(undefined); + expect(config.get('requiredKey')).toEqual(1); + expect(config.get('stringKey')).toEqual('stringValue'); + expect(config.get('booleanKey')).toEqual(undefined); }); it('will never throw', () => { @@ -46,10 +44,10 @@ describe('Config', () => { requiredKey: 1 }); - expect(config.get('arrayKey1')).to.equal(undefined); - expect(config.get('arrayKey2[0]')).to.equal(undefined); - expect(config.get('arrayKey2[0].stringKey')).to.equal(undefined); - expect(config.get('arrayKey2[0].stringKey.a.b.c.d')).to.equal(undefined); + expect(config.get('arrayKey1')).toEqual(undefined); + expect(config.get('arrayKey2[0]')).toEqual(undefined); + expect(config.get('arrayKey2[0].stringKey')).toEqual(undefined); + expect(config.get('arrayKey2[0].stringKey.a.b.c.d')).toEqual(undefined); }); }); @@ -63,28 +61,28 @@ describe('Config', () => { ); // Check on string. - expect(cliConfig.isDefined('stringKey')).to.equal(false); - expect(cliConfig.config.stringKey).to.equal('stringValue'); + expect(cliConfig.isDefined('stringKey')).toEqual(false); + expect(cliConfig.config.stringKey).toEqual('stringValue'); cliConfig.config.stringKey = 'stringValue2'; - expect(cliConfig.isDefined('stringKey')).to.equal(true); - expect(cliConfig.config.stringKey).to.equal('stringValue2'); + expect(cliConfig.isDefined('stringKey')).toEqual(true); + expect(cliConfig.config.stringKey).toEqual('stringValue2'); cliConfig.deletePath('stringKey'); - expect(cliConfig.isDefined('stringKey')).to.equal(false); - expect(cliConfig.config.stringKey).to.equal('stringValue'); + expect(cliConfig.isDefined('stringKey')).toEqual(false); + expect(cliConfig.config.stringKey).toEqual('stringValue'); // Check on number (which is 2 fallbacks behind) - expect(cliConfig.isDefined('numberKey')).to.equal(false); - expect(cliConfig.config.numberKey).to.equal(1); + expect(cliConfig.isDefined('numberKey')).toEqual(false); + expect(cliConfig.config.numberKey).toEqual(1); cliConfig.config.numberKey = 2; - expect(cliConfig.isDefined('numberKey')).to.equal(true); - expect(cliConfig.config.numberKey).to.equal(2); + expect(cliConfig.isDefined('numberKey')).toEqual(true); + expect(cliConfig.config.numberKey).toEqual(2); cliConfig.deletePath('numberKey'); - expect(cliConfig.isDefined('numberKey')).to.equal(false); - expect(cliConfig.config.numberKey).to.equal(1); + expect(cliConfig.isDefined('numberKey')).toEqual(false); + expect(cliConfig.config.numberKey).toEqual(1); }); it('saves', () => { @@ -100,7 +98,7 @@ describe('Config', () => { ] ); - expect(cliConfig.config.arrayKey2[0].stringKey).to.equal('value1'); - expect(JSON.stringify(JSON.parse(cliConfig.serialize()))).to.equal(JSON.stringify(jsonObject)); + expect(cliConfig.config.arrayKey2[0].stringKey).toEqual('value1'); + expect(JSON.stringify(JSON.parse(cliConfig.serialize()))).toEqual(JSON.stringify(jsonObject)); }); }); diff --git a/packages/angular-cli/models/config/config.ts b/packages/angular-cli/models/config/config.ts index 4968747f7cba..ba98d5398e2d 100644 --- a/packages/angular-cli/models/config/config.ts +++ b/packages/angular-cli/models/config/config.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import {SchemaClass, SchemaClassFactory} from '../json-schema/schema-class-factory'; +import {SchemaClass, SchemaClassFactory} from '@ngtools/json-schema'; const DEFAULT_CONFIG_SCHEMA_PATH = path.join(__dirname, '../../lib/config/schema.json'); diff --git a/tests/models/spec-schema.d.ts b/packages/angular-cli/models/config/spec-schema.d.ts similarity index 100% rename from tests/models/spec-schema.d.ts rename to packages/angular-cli/models/config/spec-schema.d.ts diff --git a/tests/models/spec-schema.json b/packages/angular-cli/models/config/spec-schema.json similarity index 91% rename from tests/models/spec-schema.json rename to packages/angular-cli/models/config/spec-schema.json index af3648c9fc27..9922d87cba96 100644 --- a/tests/models/spec-schema.json +++ b/packages/angular-cli/models/config/spec-schema.json @@ -39,7 +39,8 @@ "type": "object", "properties": { "stringKey": { - "type": "string" + "type": "string", + "default": "default objectKey2.stringKey" } }, "additionalProperties": true @@ -47,9 +48,7 @@ "arrayKey1": { "type": "array", "items": { - "stringKey": { - "type": "string" - } + "type": "string" } }, "arrayKey2": { diff --git a/packages/angular-cli/package.json b/packages/angular-cli/package.json index f7a22fa0cbad..316a8e173405 100644 --- a/packages/angular-cli/package.json +++ b/packages/angular-cli/package.json @@ -30,6 +30,7 @@ "@angular/compiler": "^2.3.1", "@angular/compiler-cli": "^2.3.1", "@angular/core": "^2.3.1", + "@ngtools/json-schema": "^1.0.0", "@ngtools/webpack": "^1.0.0", "async": "^2.1.4", "autoprefixer": "^6.5.3", diff --git a/packages/angular-cli/tsconfig.json b/packages/angular-cli/tsconfig.json index 818030de11d5..fc88d7050536 100644 --- a/packages/angular-cli/tsconfig.json +++ b/packages/angular-cli/tsconfig.json @@ -23,6 +23,7 @@ "@angular-cli/ast-tools": [ "../../dist/@angular-cli/ast-tools/src" ], "@angular-cli/base-href-webpack": [ "../../dist/@angular-cli/base-href-webpack/src" ], "@angular-cli/version": [ "../../dist/@angular-cli/version/src" ], + "@ngtools/json-schema": [ "../../dist/@ngtools/json-schema/src" ], "@ngtools/webpack": [ "../../dist/@ngtools/webpack/src" ] } }, diff --git a/scripts/build-schema-dts.js b/scripts/build-schema-dts.js new file mode 100644 index 000000000000..3baa3608be6f --- /dev/null +++ b/scripts/build-schema-dts.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const minimist = require('minimist'); + +// Load the bootstrap. +require('../lib/bootstrap-local'); +const SchemaClassFactory = require('@ngtools/json-schema').SchemaClassFactory; + +const argv = minimist(process.argv.slice(2)); +const inFile = argv._[0]; +const outFile = argv._[1]; + +if (!inFile) { + process.stderr.write('Need to pass in an input file.\n'); + process.exit(1); +} +const jsonSchema = JSON.parse(fs.readFileSync(inFile, 'utf-8')); +const SchemaClass = SchemaClassFactory(jsonSchema); +const schemaInstance = new SchemaClass(); +const serialized = schemaInstance.$$serialize('text/x.dts', 'CliConfig'); + +if (outFile) { + fs.writeFileSync(outFile, serialized, 'utf-8'); +} else { + process.stdout.write(serialized); +} diff --git a/scripts/publish/build.js b/scripts/publish/build.js index a971efe00495..e07b8199d314 100755 --- a/scripts/publish/build.js +++ b/scripts/publish/build.js @@ -56,6 +56,13 @@ function getDeps(pkg) { Promise.resolve() .then(() => console.log('Deleting dist folder...')) .then(() => rimraf(dist)) + .then(() => console.log('Creating schema.d.ts...')) + .then(() => { + const script = path.join(root, 'scripts/build-schema-dts.js'); + const input = path.join(root, 'packages/angular-cli/lib/config/schema.json'); + const output = path.join(root, 'packages/angular-cli/lib/config/schema.d.ts'); + return npmRun.execSync(`node ${script} ${input} ${output}`); + }) .then(() => console.log('Compiling packages...')) .then(() => { const packages = require('../../lib/packages');