diff --git a/package.json b/package.json index edd33f6..990a7f4 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "prebuild": "run-s -s clean lint", "build": "tsc", "postbuild": "chmod +x ./lib/rpc.js", - "lint:eslint": "eslint src/**/*.*", - "fix:eslint": "eslint --fix src/**/*.*", + "lint:eslint": "eslint \"src/**/*.*\"", + "fix:eslint": "eslint --fix \"src/**/*.*\"", "lint:prettier": "prettier -c .", "fix:prettier": "prettier -w .", "clean:coverage": "rimraf coverage", diff --git a/src/json-schema-parser.ts b/src/json-schema-parser.ts index 8f92317..360883d 100644 --- a/src/json-schema-parser.ts +++ b/src/json-schema-parser.ts @@ -5,6 +5,8 @@ import { Enum, EnumMember, IntegerLiteral, + MapProperties, + MapValue, MemberValue, Parser, Primitive, @@ -20,7 +22,7 @@ import { Violation, } from 'basketry'; import parse = require('json-to-ast'); -import { getName, resolve, LiteralNode, Literal } from './json'; +import { getName, isLiteralNode, resolve, LiteralNode, Literal } from './json'; import * as AST from './json-schema'; import { parseObjectValidationRules, @@ -324,6 +326,7 @@ class JsonSchemaParser { name, description: toDescription(schema.description), members, + disjunction: { kind: 'DisjunctionKindLiteral', value: 'exclusive' }, loc, }); } @@ -354,7 +357,28 @@ class JsonSchemaParser { loc: string | undefined, ): MemberValue { if (schema.anyOf) { - // TODO + const members: MemberValue[] = schema.anyOf.map( + (member) => + this.parseType(member, encodeRange(0, member.loc)).memberValue, + ); + const name = this.parseTypeName(schema); + + if (!name) return untyped(); + + this.unions.set(name.value, { + kind: 'SimpleUnion', + name, + description: toDescription(schema.description), + members, + disjunction: { kind: 'DisjunctionKindLiteral', value: 'inclusive' }, + loc, + }); + + return { + kind: 'ComplexValue', + typeName: name, + rules: [], + }; } return untyped(); @@ -469,11 +493,14 @@ class JsonSchemaParser { .map((child) => this.parseProperty(child)) .filter((prop): prop is Property => !!prop); + const mapProperties = this.parseMapProperties(schema, typeName); + this.types.set(typeName.value, { kind: 'Type', name: typeName, description: toDescription(schema.description), properties: properties || [], + mapProperties, rules: Array.from(parseObjectValidationRules(schema)), loc, }); @@ -632,6 +659,104 @@ class JsonSchemaParser { return untyped(); } + private parseMapProperties( + schema: AST.AbstractSchemaNode, + typeName: StringLiteral, + ): MapProperties | undefined { + const ap = schema.additionalProperties; + if (!ap) return undefined; + + // Handle boolean literal additionalProperties (true/false) + if (isLiteralNode(ap.node)) { + // false is handled by objectAdditionalPropertiesRule; true means untyped map + if (ap.node.value !== true) return undefined; + return { + kind: 'MapProperties', + key: { + kind: 'MapKey', + value: { + kind: 'PrimitiveValue', + typeName: { kind: 'PrimitiveLiteral', value: 'string' }, + rules: [], + }, + }, + requiredKeys: [], + value: { + kind: 'MapValue', + value: untyped(), + }, + }; + } + + return { + kind: 'MapProperties', + key: { + kind: 'MapKey', + value: { + kind: 'PrimitiveValue', + typeName: { kind: 'PrimitiveLiteral', value: 'string' }, + rules: [], + }, + }, + requiredKeys: [], + value: this.parseMapValue(ap, typeName.value), + }; + } + + private parseMapValue( + schema: AST.AbstractSchemaNode, + parentName: string, + ): MapValue { + const isInline = !schema.ref; + const { memberValue } = this.parseType(schema, encodeRange(0, schema.loc)); + + if (isInline && memberValue.kind === 'ComplexValue') { + const autoName = memberValue.typeName.value; + + const desiredName = + schema.oneOf || schema.anyOf + ? `${parentName}MapValues` + : schema.enum + ? `${parentName}MapValue` + : `${parentName}MapValues`; + + if (autoName !== desiredName) { + if (this.enums.has(autoName)) { + const existing = this.enums.get(autoName)!; + this.enums.set(desiredName, { + ...existing, + name: { kind: 'StringLiteral', value: desiredName }, + }); + this.enums.delete(autoName); + } else if (this.unions.has(autoName)) { + const existing = this.unions.get(autoName)!; + this.unions.set(desiredName, { + ...existing, + name: { kind: 'StringLiteral', value: desiredName }, + }); + this.unions.delete(autoName); + } else if (this.types.has(autoName)) { + const existing = this.types.get(autoName)!; + this.types.set(desiredName, { + ...existing, + name: { kind: 'StringLiteral', value: desiredName }, + }); + this.types.delete(autoName); + } + + return { + kind: 'MapValue', + value: { + ...memberValue, + typeName: { kind: 'StringLiteral', value: desiredName }, + }, + }; + } + } + + return { kind: 'MapValue', value: memberValue }; + } + parseIsOptional(schema: AST.AbstractSchemaNode): TrueLiteral | undefined { if (this.parseIsRequired(schema)) return undefined; diff --git a/src/snapshot/snapshot.json b/src/snapshot/snapshot.json index 1563e79..047bed6 100644 --- a/src/snapshot/snapshot.json +++ b/src/snapshot/snapshot.json @@ -4363,6 +4363,7 @@ "rules": [] } ], + "disjunction": { "kind": "DisjunctionKindLiteral", "value": "exclusive" }, "loc": "0:426;5;432;6;9160;9360" } ] diff --git a/src/spec/4.1-structure/4.1.11-map-properties.spec.ts b/src/spec/4.1-structure/4.1.11-map-properties.spec.ts new file mode 100644 index 0000000..9e0892d --- /dev/null +++ b/src/spec/4.1-structure/4.1.11-map-properties.spec.ts @@ -0,0 +1,795 @@ +import { Service } from '@basketry/ir'; +import parser from '../..'; + +const absoluteSourcePath = '/test/path.ext'; + +describe('4.1.11 MapProperties', () => { + it('handles a boolean', async () => { + // ARRANGE + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + title: 'wrapper', + additionalProperties: true, + }; + + const content = JSON.stringify(schema); + + // ACT + const result = await parser(content, absoluteSourcePath); + + // ASSERT + expect(result.service).toEqual( + partial({ + kind: 'Service', + types: [ + { + kind: 'Type', + name: { value: 'wrapper' }, + properties: [], + mapProperties: { + kind: 'MapProperties', + key: { + kind: 'MapKey', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'string' }, + }, + }, + value: { + kind: 'MapValue', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'untyped' }, + }, + }, + }, + }, + ], + }), + ); + }); + + describe('type', () => { + it('handles a referenced type', async () => { + // ARRANGE + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + title: 'wrapper', + additionalProperties: { + $ref: '#/definitions/extraValue', + }, + definitions: { + extraValue: { + type: 'object', + required: ['value'], + properties: { + value: { + type: 'string', + }, + }, + additionalProperties: false, + }, + }, + }; + + const content = JSON.stringify(schema); + + // ACT + const result = await parser(content, absoluteSourcePath); + + // ASSERT + expect(result.service).toEqual( + partial({ + kind: 'Service', + types: [ + { + kind: 'Type', + name: { value: 'extraValue' }, + properties: [ + { + kind: 'Property', + name: { value: 'value' }, + value: { + kind: 'PrimitiveValue', + typeName: { value: 'string' }, + }, + }, + ], + }, + { + kind: 'Type', + name: { value: 'wrapper' }, + properties: [], + mapProperties: { + kind: 'MapProperties', + key: { + kind: 'MapKey', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'string' }, + }, + }, + value: { + kind: 'MapValue', + value: { + kind: 'ComplexValue', + typeName: { value: 'extraValue' }, + }, + }, + }, + }, + ], + }), + ); + }); + }); + + describe('primitive', () => { + it('handles a direct string primitive', async () => { + // ARRANGE + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + title: 'wrapper', + additionalProperties: { + type: 'string', + }, + }; + + const content = JSON.stringify(schema); + + // ACT + const result = await parser(content, absoluteSourcePath); + + // ASSERT + expect(result.service).toEqual( + partial({ + kind: 'Service', + types: [ + { + kind: 'Type', + name: { value: 'wrapper' }, + properties: [], + mapProperties: { + kind: 'MapProperties', + key: { + kind: 'MapKey', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'string' }, + }, + }, + value: { + kind: 'MapValue', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'string' }, + }, + }, + }, + }, + ], + }), + ); + }); + + it('handles a referenced string primitive', async () => { + // ARRANGE + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + title: 'wrapper', + additionalProperties: { + $ref: '#/definitions/stringValue', + }, + definitions: { + stringValue: { + type: 'string', + }, + }, + }; + + const content = JSON.stringify(schema); + + // ACT + const result = await parser(content, absoluteSourcePath); + + // ASSERT + expect(result.service).toEqual( + partial({ + kind: 'Service', + types: [ + { + kind: 'Type', + name: { value: 'wrapper' }, + properties: [], + mapProperties: { + kind: 'MapProperties', + key: { + kind: 'MapKey', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'string' }, + }, + }, + value: { + kind: 'MapValue', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'string' }, + }, + }, + }, + }, + ], + }), + ); + }); + + it('handles a direct number primitive', async () => { + // ARRANGE + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + title: 'wrapper', + additionalProperties: { + type: 'number', + }, + }; + + const content = JSON.stringify(schema); + + // ACT + const result = await parser(content, absoluteSourcePath); + + // ASSERT + expect(result.service).toEqual( + partial({ + kind: 'Service', + types: [ + { + kind: 'Type', + name: { value: 'wrapper' }, + properties: [], + mapProperties: { + kind: 'MapProperties', + key: { + kind: 'MapKey', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'string' }, + }, + }, + value: { + kind: 'MapValue', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'number' }, + }, + }, + }, + }, + ], + }), + ); + }); + + it('handles a referenced number primitive', async () => { + // ARRANGE + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + title: 'wrapper', + additionalProperties: { + $ref: '#/definitions/numberValue', + }, + definitions: { + numberValue: { + type: 'number', + }, + }, + }; + + const content = JSON.stringify(schema); + + // ACT + const result = await parser(content, absoluteSourcePath); + + // ASSERT + expect(result.service).toEqual( + partial({ + kind: 'Service', + types: [ + { + kind: 'Type', + name: { value: 'wrapper' }, + properties: [], + mapProperties: { + kind: 'MapProperties', + key: { + kind: 'MapKey', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'string' }, + }, + }, + value: { + kind: 'MapValue', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'number' }, + }, + }, + }, + }, + ], + }), + ); + }); + }); + + describe('enum', () => { + it('handles a direct inline enum', async () => { + // ARRANGE + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + title: 'wrapper', + additionalProperties: { + type: 'string', + enum: ['a', 'b'], + }, + }; + + const content = JSON.stringify(schema); + + // ACT + const result = await parser(content, absoluteSourcePath); + + // ASSERT + expect(result.service).toEqual( + partial({ + kind: 'Service', + types: [ + { + kind: 'Type', + name: { value: 'wrapper' }, + properties: [], + mapProperties: { + kind: 'MapProperties', + key: { + kind: 'MapKey', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'string' }, + }, + }, + value: { + kind: 'MapValue', + value: { + kind: 'ComplexValue', + typeName: { value: 'wrapperMapValue' }, + }, + }, + }, + }, + ], + enums: [ + { + kind: 'Enum', + name: { value: 'wrapperMapValue' }, + members: [ + { kind: 'EnumMember', content: { value: 'a' } }, + { kind: 'EnumMember', content: { value: 'b' } }, + ], + }, + ], + }), + ); + }); + + it('handles a referenced enum', async () => { + // ARRANGE + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + title: 'wrapper', + additionalProperties: { + $ref: '#/definitions/statusEnum', + }, + definitions: { + statusEnum: { + type: 'string', + enum: ['active', 'inactive'], + }, + }, + }; + + const content = JSON.stringify(schema); + + // ACT + const result = await parser(content, absoluteSourcePath); + + // ASSERT + expect(result.service).toEqual( + partial({ + kind: 'Service', + types: [ + { + kind: 'Type', + name: { value: 'wrapper' }, + properties: [], + mapProperties: { + kind: 'MapProperties', + key: { + kind: 'MapKey', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'string' }, + }, + }, + value: { + kind: 'MapValue', + value: { + kind: 'ComplexValue', + typeName: { value: 'statusEnum' }, + }, + }, + }, + }, + ], + enums: [ + { + kind: 'Enum', + name: { value: 'statusEnum' }, + members: [ + { kind: 'EnumMember', content: { value: 'active' } }, + { kind: 'EnumMember', content: { value: 'inactive' } }, + ], + }, + ], + }), + ); + }); + }); + + describe('union', () => { + it('handles a direct oneOf union', async () => { + // ARRANGE + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + title: 'wrapper', + additionalProperties: { + oneOf: [ + { $ref: '#/definitions/typeA' }, + { $ref: '#/definitions/typeB' }, + ], + }, + definitions: { + typeA: { + type: 'object', + properties: { a: { type: 'string' } }, + }, + typeB: { + type: 'object', + properties: { b: { type: 'number' } }, + }, + }, + }; + + const content = JSON.stringify(schema); + + // ACT + const result = await parser(content, absoluteSourcePath); + + // ASSERT + expect(result.service).toEqual( + partial({ + kind: 'Service', + types: [ + { kind: 'Type', name: { value: 'typeA' } }, + { kind: 'Type', name: { value: 'typeB' } }, + { + kind: 'Type', + name: { value: 'wrapper' }, + properties: [], + mapProperties: { + kind: 'MapProperties', + key: { + kind: 'MapKey', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'string' }, + }, + }, + value: { + kind: 'MapValue', + value: { + kind: 'ComplexValue', + typeName: { value: 'wrapperMapValues' }, + }, + }, + }, + }, + ], + unions: [ + { + kind: 'SimpleUnion', + name: { value: 'wrapperMapValues' }, + members: [ + { typeName: { value: 'typeA' } }, + { typeName: { value: 'typeB' } }, + ], + disjunction: { value: 'exclusive' }, + }, + ], + }), + ); + }); + + it('handles a referenced oneOf union', async () => { + // ARRANGE + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + title: 'wrapper', + additionalProperties: { + $ref: '#/definitions/unionAB', + }, + definitions: { + typeA: { + type: 'object', + properties: { a: { type: 'string' } }, + }, + typeB: { + type: 'object', + properties: { b: { type: 'number' } }, + }, + unionAB: { + oneOf: [ + { $ref: '#/definitions/typeA' }, + { $ref: '#/definitions/typeB' }, + ], + }, + }, + }; + + const content = JSON.stringify(schema); + + // ACT + const result = await parser(content, absoluteSourcePath); + + // ASSERT + expect(result.service).toEqual( + partial({ + kind: 'Service', + types: [ + { kind: 'Type', name: { value: 'typeA' } }, + { kind: 'Type', name: { value: 'typeB' } }, + { + kind: 'Type', + name: { value: 'wrapper' }, + properties: [], + mapProperties: { + kind: 'MapProperties', + key: { + kind: 'MapKey', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'string' }, + }, + }, + value: { + kind: 'MapValue', + value: { + kind: 'ComplexValue', + typeName: { value: 'unionAB' }, + }, + }, + }, + }, + ], + unions: [ + { + kind: 'SimpleUnion', + name: { value: 'unionAB' }, + members: [ + { typeName: { value: 'typeA' } }, + { typeName: { value: 'typeB' } }, + ], + disjunction: { value: 'exclusive' }, + }, + ], + }), + ); + }); + + it('handles a direct anyOf union', async () => { + // ARRANGE + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + title: 'wrapper', + additionalProperties: { + anyOf: [ + { $ref: '#/definitions/typeA' }, + { $ref: '#/definitions/typeB' }, + ], + }, + definitions: { + typeA: { + type: 'object', + properties: { a: { type: 'string' } }, + }, + typeB: { + type: 'object', + properties: { b: { type: 'number' } }, + }, + }, + }; + + const content = JSON.stringify(schema); + + // ACT + const result = await parser(content, absoluteSourcePath); + + // ASSERT + expect(result.service).toEqual( + partial({ + kind: 'Service', + types: [ + { kind: 'Type', name: { value: 'typeA' } }, + { kind: 'Type', name: { value: 'typeB' } }, + { + kind: 'Type', + name: { value: 'wrapper' }, + properties: [], + mapProperties: { + kind: 'MapProperties', + key: { + kind: 'MapKey', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'string' }, + }, + }, + value: { + kind: 'MapValue', + value: { + kind: 'ComplexValue', + typeName: { value: 'wrapperMapValues' }, + }, + }, + }, + }, + ], + unions: [ + { + kind: 'SimpleUnion', + name: { value: 'wrapperMapValues' }, + members: [ + { typeName: { value: 'typeA' } }, + { typeName: { value: 'typeB' } }, + ], + disjunction: { value: 'inclusive' }, + }, + ], + }), + ); + }); + + it('handles a referenced anyOf union', async () => { + // ARRANGE + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + title: 'wrapper', + additionalProperties: { + $ref: '#/definitions/unionAB', + }, + definitions: { + typeA: { + type: 'object', + properties: { a: { type: 'string' } }, + }, + typeB: { + type: 'object', + properties: { b: { type: 'number' } }, + }, + unionAB: { + anyOf: [ + { $ref: '#/definitions/typeA' }, + { $ref: '#/definitions/typeB' }, + ], + }, + }, + }; + + const content = JSON.stringify(schema); + + // ACT + const result = await parser(content, absoluteSourcePath); + + // ASSERT + expect(result.service).toEqual( + partial({ + kind: 'Service', + types: [ + { kind: 'Type', name: { value: 'typeA' } }, + { kind: 'Type', name: { value: 'typeB' } }, + { + kind: 'Type', + name: { value: 'wrapper' }, + properties: [], + mapProperties: { + kind: 'MapProperties', + key: { + kind: 'MapKey', + value: { + kind: 'PrimitiveValue', + typeName: { value: 'string' }, + }, + }, + value: { + kind: 'MapValue', + value: { + kind: 'ComplexValue', + typeName: { value: 'unionAB' }, + }, + }, + }, + }, + ], + unions: [ + { + kind: 'SimpleUnion', + name: { value: 'unionAB' }, + members: [ + { typeName: { value: 'typeA' } }, + { typeName: { value: 'typeB' } }, + ], + disjunction: { value: 'inclusive' }, + }, + ], + }), + ); + }); + }); +}); + +type DeepPartial = T extends Function + ? T + : T extends Array + ? Array> + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends object + ? { [P in keyof T]?: DeepPartial } + : T; + +const exactSet = new Set(); +function exact(input: T): T { + exactSet.add(input); + return input; +} + +function partial(input: DeepPartial): any { + if (exactSet.has(input)) return input; + + if (Array.isArray(input)) { + return expect.arrayContaining(input.map(partial)); + } + + if (input && typeof input === 'object' && !(input instanceof Date)) { + const entries = Object.entries(input).map(([key, value]) => [ + key, + partial(value), + ]); + return expect.objectContaining(Object.fromEntries(entries)); + } + + return input; +}