diff --git a/src/keywords.ts b/src/keywords.ts new file mode 100644 index 0000000..86e9278 --- /dev/null +++ b/src/keywords.ts @@ -0,0 +1,20 @@ +const KEYWORDS = { + python: new Set(["false", "none", "true", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with", "yield"]), + go: new Set(["break", "default", "func", "interface", "select", "case", "defer", "go", "map", "struct", "chan", "else", "goto", "package", "switch", "const", "fallthrough", "if", "range", "type", "continue", "for", "import", "return", "var"]), + csharp: new Set(["abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", "class", "const", "continue", "decimal", "default", "delegate", "do", "double", "else", "enum", "event", "explicit", "extern", "false", "finally", "fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int", "interface", "internal", "is", "lock", "long", "namespace", "new", "null", "object", "operator", "out", "override", "params", "private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof", "stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", "try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", "void", "volatile", "while"]), + rust: new Set(["as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use", "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final", "macro", "override", "priv", "try", "typeof", "unsized", "virtual", "yield"]), + cpp: new Set(["alignas", "alignof", "and", "and_eq", "asm", "auto", "bitand", "bitor", "bool", "break", "case", "catch", "char", "char8_t", "char16_t", "char32_t", "class", "compl", "concept", "const", "consteval", "constexpr", "constinit", "const_cast", "continue", "co_await", "co_return", "co_yield", "decltype", "default", "delete", "do", "double", "dynamic_cast", "else", "enum", "explicit", "export", "extern", "false", "float", "for", "friend", "goto", "if", "inline", "int", "long", "mutable", "namespace", "new", "noexcept", "not", "not_eq", "nullptr", "operator", "or", "or_eq", "private", "protected", "public", "register", "reinterpret_cast", "requires", "return", "short", "signed", "sizeof", "static", "static_assert", "static_cast", "struct", "switch", "template", "this", "thread_local", "throw", "true", "try", "typedef", "typeid", "typename", "union", "unsigned", "using", "virtual", "void", "volatile", "wchar_t", "while", "xor", "xor_eq"]), + zig: new Set(["addrspace", "align", "allowzero", "and", "anyframe", "anytype", "asm", "async", "await", "break", "callconv", "catch", "comptime", "const", "continue", "defer", "else", "enum", "errdefer", "error", "export", "extern", "fn", "for", "if", "inline", "linksection", "noalias", "noinline", "nosuspend", "opaque", "or", "orelse", "packed", "pub", "resume", "return", "struct", "suspend", "switch", "test", "threadlocal", "try", "union", "unreachable", "usingnamespace", "var", "volatile", "while"]), +} + +export function checkForKeyword(name: string): string[] | null { + const normalizedName = name.toLowerCase() + const langs = [] + for (const lang in KEYWORDS) { + // @ts-ignore + if (KEYWORDS[lang].has(normalizedName)) { + langs.push(lang) + } + } + return langs.length > 0 ? langs : null +} diff --git a/src/normalizer.ts b/src/normalizer.ts index 9c52a33..6ac855d 100644 --- a/src/normalizer.ts +++ b/src/normalizer.ts @@ -65,7 +65,7 @@ export type SchemaMap = { } // Main Schema export interface -export interface XtpSchema { +export interface XtpSchema extends parser.ParseResults { version: Version; exports: Export[]; imports: Import[]; @@ -93,10 +93,12 @@ export function isExport(e: any): e is Export { // These are the same for now export type Import = Export -function normalizeV0Schema(parsed: parser.V0Schema): { schema: XtpSchema, errors: ValidationError[] } { +function normalizeV0Schema(parsed: parser.V0Schema): XtpSchema { const exports: Export[] = [] const imports: Import[] = [] const schemas = {} + const errors = parsed.errors + const warnings = parsed.warnings parsed.exports.forEach(ex => { exports.push({ @@ -105,13 +107,12 @@ function normalizeV0Schema(parsed: parser.V0Schema): { schema: XtpSchema, errors }) return { - schema: { - version: 'v0', - exports, - imports, - schemas, - }, - errors: [] + version: 'v0', + exports, + imports, + schemas, + errors, + warnings, } } @@ -204,75 +205,42 @@ class V1SchemaNormalizer { imports: Import[] = [] schemas: SchemaMap = {} parsed: parser.V1Schema - errors: ValidationError[] = [] - location: string[] = ['#'] + errors: ValidationError[] + warnings: ValidationError[] constructor(parsed: parser.V1Schema) { this.parsed = parsed - } - - private recordError(msg: string, additionalPath?: string[]) { - const path = additionalPath ? [...this.location, ...additionalPath] : this.location - this.errors.push( - new ValidationError(msg, path.join('/')) - ) + this.errors = parsed.errors + this.warnings = parsed.warnings } normalize(): XtpSchema { // First let's create all our normalized schemas if (this.parsed.components?.schemas) { - this.location.push('components'); - this.location.push('schemas'); - for (const name in this.parsed.components.schemas) { - this.location.push(name); - try { - if (!this.validateIdentifier(name, [])) { - continue; - } - - const pSchema = this.parsed.components.schemas[name]; - - // validate that required properties are defined - if (pSchema.required) { - for (const name of pSchema.required) { - if (!pSchema.properties?.[name]) { - this.recordError(`Property ${name} is required but not defined`, ['required']); - } - } + const pSchema = this.parsed.components.schemas[name]; + + // turn any parser.Property map we have into Property[] + const properties = [] + if (pSchema.properties) { + for (const name in pSchema.properties) { + // set `required` on the property for convenience + const required = pSchema.required?.includes(name) + properties.push({ ...pSchema.properties[name], name, required } as Property) } - - // turn any parser.Property map we have into Property[] - const properties = [] - if (pSchema.properties) { - for (const name in pSchema.properties) { - if (!this.validateIdentifier(name, ['properties', name])) { - continue; - } - - const required = pSchema.required?.includes(name) - properties.push({ ...pSchema.properties[name], name, required } as Property) - } - } - - // we hard cast instead of copy we we can mutate the $refs later - // TODO find a way around this - const schema = (pSchema as unknown) as Schema - schema.name = name - schema.properties = properties - this.schemas[name] = schema - - } finally { - this.location.pop(); } - } - this.location.pop(); - this.location.pop(); + // we hard cast instead of copy we we can mutate the $refs later + // TODO find a way around this + const schema = (pSchema as unknown) as Schema + schema.name = name + schema.properties = properties + this.schemas[name] = schema + } } // recursively annotate all typed interfaces in the document - this.annotateType(this.parsed as any, []) + this.annotateType(this.parsed as any) // detect cycles in schema references const cycleContext: CycleDetectionContext = { @@ -292,10 +260,6 @@ class V1SchemaNormalizer { if (this.parsed.exports) { for (const name in this.parsed.exports) { - if (!this.validateIdentifier(name, ['exports', name])) { - continue; - } - const ex = this.parsed.exports[name] as Export ex.name = name this.exports.push(ex) @@ -305,10 +269,6 @@ class V1SchemaNormalizer { // normalize imports if (this.parsed.imports) { for (const name in this.parsed.imports) { - if (!this.validateIdentifier(name, ['imports', name])) { - continue; - } - const im = this.parsed.imports[name] as Import im.name = name this.imports.push(im) @@ -320,39 +280,37 @@ class V1SchemaNormalizer { exports: this.exports, imports: this.imports, schemas: this.schemas, + errors: this.errors, + warnings: this.warnings, } } - querySchemaRef(ref: string, path: string[]): Schema | null { + // NOTE: we may want to relax this again so we can keep normalizing + // even if a ref is invalid + querySchemaRef(ref: string): Schema { const parts = ref.split('/') - if (parts[0] !== '#' || parts[1] !== 'components' || parts[2] !== 'schemas') { - this.recordError("Not a valid ref " + ref, path); - return null; - } - - const name = parts[3]; - const s = this.schemas[name] - if (!s) { - const availableSchemas = Object.keys(this.schemas).join(', ') - this.recordError(`Invalid reference ${ref}. Cannot find schema ${name}. Options are: [${availableSchemas}]`, path); - return null; - } - - return s + const name = parts[3] + return this.schemas[name]! } - annotateType(s: any, path: string[]): XtpNormalizedType | undefined { + annotateType(s: any): XtpNormalizedType | undefined { if (!s || typeof s !== 'object' || Array.isArray(s)) return undefined if (s.xtpType) return s.xtpType + // This pattern should be validated in the parser + if (s.type && s.type === 'object' && s.additionalProperties) { + s.type = 'map' + const valueType = this.annotateType(s.additionalProperties) + return valueType ? new MapType(valueType, s) : undefined + } + + // if we have properties, we should be able to assume it's an object if (s.properties && s.properties.length > 0) { s.type = 'object' - const properties: XtpNormalizedType[] = [] for (const pname in s.properties) { const p = s.properties[pname] - - const t = this.annotateType(p, [...path, 'properties', p.name ?? pname]) + const t = this.annotateType(p) if (t) { p.xtpType = t properties.push(t) @@ -366,44 +324,28 @@ class V1SchemaNormalizer { if (s.type) { return undefined } - let ref = s.$ref + + // we're ovewriting this string $ref with the link to the + // node that we find via query it may or may not have + // been overwritten already if (typeof s.$ref === 'string') { - ref = this.querySchemaRef(s.$ref, [...path, '$ref']) - if (ref) { - s.$ref = ref - } + s.$ref = this.querySchemaRef(s.$ref) } s.type = 'object' - const result = ref ? this.annotateType(ref, [...path, '$ref']) : undefined; - return result; + return this.annotateType(s.$ref) } if (s.enum) { - for (const item of s.enum) { - if (typeof item !== 'string') { - this.recordError(`Enum item must be a string: ${item}`); - return undefined - } - - this.validateIdentifier(item, [...path, 'enum']); - } - s.type = 'enum' return new EnumType(s.name || '', new StringType(), s.enum, s) } if (s.items) { - const itemType = this.annotateType(s.items, [...path, 'items']) + const itemType = this.annotateType(s.items) return itemType ? new ArrayType(itemType, s) : undefined } - if (s.additionalProperties) { - s.type = 'map' - const valueType = this.annotateType(s.additionalProperties, [...path, 'additionalProperties']) - return valueType ? new MapType(valueType, s) : undefined - } - switch (s.type) { case 'string': return s.format === 'date-time' ? new DateTimeType(s) : new StringType(s) @@ -435,44 +377,29 @@ class V1SchemaNormalizer { if (Object.prototype.hasOwnProperty.call(s, key)) { const child = s[key] if (child && typeof child === 'object' && !Array.isArray(child)) { - const t = this.annotateType(child, [...path, key]); + const t = this.annotateType(child); if (t) child.xtpType = t } } } return undefined } - - validateIdentifier(name: string, path: string[]): boolean { - if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) { - this.recordError(`Invalid identifier: "${name}". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/`, path); - return false; - } - - return true; - } } -function normalizeV1Schema(parsed: parser.V1Schema): { schema: XtpSchema, errors: ValidationError[] } { +function normalizeV1Schema(parsed: parser.V1Schema): XtpSchema { const normalizer = new V1SchemaNormalizer(parsed) const schema = normalizer.normalize() - return { schema, errors: normalizer.errors } + return schema } export function parseAndNormalizeJson(encoded: string): XtpSchema { - const { doc, errors } = parser.parseAny(JSON.parse(encoded)) - assert(errors) + const doc = parser.parseAny(JSON.parse(encoded)) + assert(doc) if (parser.isV0Schema(doc)) { - const { schema, errors } = normalizeV0Schema(doc) - assert(errors) - - return schema + return assert(normalizeV0Schema(doc)) } else if (parser.isV1Schema(doc)) { - const { schema, errors } = normalizeV1Schema(doc) - assert(errors) - - return schema + return assert(normalizeV1Schema(doc)) } else { throw new NormalizeError("Could not normalize unknown version of schema", [{ message: "Could not normalize unknown version of schema", @@ -481,7 +408,8 @@ export function parseAndNormalizeJson(encoded: string): XtpSchema { } } -function assert(errors: ValidationError[] | undefined): void { +function assert(results: parser.ParseResults): any { + const { errors } = results if (errors && errors.length > 0) { if (errors.length === 1) { throw new NormalizeError(errors[0].message, errors) @@ -489,6 +417,7 @@ function assert(errors: ValidationError[] | undefined): void { throw new NormalizeError(`${errors[0].message} (and ${errors.length - 1} other error(s))`, errors) } } + return results } export class NormalizeError extends Error { diff --git a/src/parser.ts b/src/parser.ts index 078137a..f2707fb 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -5,31 +5,28 @@ * of the raw format. */ import { ValidationError } from "./common" - -export interface ParseResult { - doc?: VUnknownSchema; - errors?: ValidationError[]; - warnings?: ValidationError[]; -} +import { checkForKeyword } from "./keywords"; /** * Parses and validates an untyped object into a V*Schema */ -export function parseAny(doc: any): ParseResult { +export function parseAny(doc: any): VUnknownSchema { + doc.errors = [] + doc.warnings = [] switch (doc.version) { case 'v0': - return { doc: doc as V0Schema } + const v0Doc = doc as V0Schema + return v0Doc case 'v1-draft': const v1Doc = doc as V1Schema const validator = new V1Validator(v1Doc) - const errors = validator.validate() - return { doc: v1Doc, errors } + validator.validate() + return v1Doc default: - return { - errors: [ - new ValidationError(`version property not valid: ${doc.version}`, "#/version") - ] - } + doc.errors.push( + new ValidationError(`version property not valid: ${doc.version}`, "#/version") + ) + return doc } } @@ -45,23 +42,21 @@ export function isV1Schema(schema?: VUnknownSchema): schema is V1Schema { * Validates a V1 document. */ class V1Validator { - errors: ValidationError[] - location: string[] + location: string[] = ['#'] doc: any constructor(doc: V1Schema) { this.doc = doc as any - this.errors = [] - this.location = ['#'] } /** - * Validate the document and return any errors + * Validate the document and sets any errors or warnings on the document */ - validate(): ValidationError[] { - this.errors = [] + validate() { + this.doc.errors = [] + this.doc.warnings = [] + this.validateRootNames() this.validateNode(this.doc) - return this.errors } /** @@ -103,16 +98,43 @@ class V1Validator { } } - recordError(msg: string) { - this.errors.push( - new ValidationError(msg, this.getLocation()) + /** + * Validates the root names of the doc. + * We just do this by hand as it's tricky to do recursively + */ + validateRootNames() { + const exports = this.doc.exports || {} + for (const n in exports) { + this.validateIdentifier(n, ['exports', n]) + } + const imports = this.doc.imports || {} + for (const n in imports) { + this.validateIdentifier(n, ['imports', n]) + } + const schemas = this.doc.components?.schemas || {} + for (const n in schemas) { + this.validateIdentifier(n, ['components', 'schemas', n]) + } + } + + recordError(msg: string, suffix?: Array) { + const path = this.getLocation(suffix) + this.doc.errors.push( + new ValidationError(msg, path) + ) + } + + recordWarning(msg: string, suffix?: Array) { + const path = this.getLocation(suffix) + this.doc.warnings.push( + new ValidationError(msg, path) ) } /** * Validates that a node conforms to the rules of - * the XtpTyped interface. Validates what we can't - * catch in JSON Schema validation. + * the XtpTyped interface. These validations catch a lot of + * what we can't catch in JSON Schema validation. */ validateTypedInterface(prop?: XtpTyped): void { if (!prop) return @@ -139,18 +161,79 @@ class V1Validator { } // TODO consider adding properties to XtpTyped when we support inlining objects + // for now we'll use the presence of `properties` as a hint to cast to Schema if ('properties' in prop && Object.keys(prop.properties!).length > 0) { - if (prop.additionalProperties) { + const schema = prop as Schema + // check for mixing of additional and fixed props + if (schema.additionalProperties) { this.recordError('We currently do not support objects with both fixed properties and additionalProperties') } + + // validate the required array + if (schema.required) { + for (const name of schema.required) { + if (!schema.properties?.[name]) { + this.recordError(`Property ${name} is marked as required but not defined`); + } + } + } + + // validate the property names + for (const name in schema.properties) { + this.validateIdentifier(name, ['properties', name]) + } + } + + // validate enum items if they exists + if (prop.enum) { + for (const item of prop.enum) { + if (typeof item !== 'string') { + this.recordError(`Enum item must be a string: ${item}`); + } + this.validateIdentifier(item, ['enum']); + } } if (prop.items) this.validateTypedInterface(prop.items) - if (prop.additionalProperties) this.validateTypedInterface(prop.additionalProperties) + if (prop.additionalProperties) { + if (prop.type !== 'object') { + this.recordError(`The parent type must be 'object' when using additionalProperties but your type is ${prop.type}`) + } + this.validateTypedInterface(prop.additionalProperties) + } + + // if we have a $ref, validate it + if (prop.$ref) { + const parts = prop.$ref.split('/') + // for now we can only link to schemas + // TODO we should be able to link to any valid type in the future + if (parts[0] === '#' && parts[1] === 'components' && parts[2] === 'schemas') { + const name = parts[3] + const schemas = this.doc.components?.schemas || {} + const s = schemas[name] + if (!s) { + const availableSchemas = Object.keys(schemas).join(', ') + this.recordError(`Invalid $ref "${prop.$ref}". Cannot find schema "${name}". Options are: [${availableSchemas}]`, ['$ref']); + } + } else { + this.recordError(`Invalid $ref "${prop.$ref}"`, ['$ref']) + } + } + } + + validateIdentifier(name: string, path?: string[]) { + if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) { + this.recordError(`Invalid identifier: "${name}". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/`, path); + } + + const langs = checkForKeyword(name) + if (langs) { + this.recordWarning(`Potentially Invalid identifier: "${name}". This is a keyword in the following languages and may cause trouble with code generation: ${langs.join(',')}`, path) + } } - getLocation(): string { - return this.location.join('/') + getLocation(suffix: string[] = []): string { + return this.location.concat(suffix).join('/') } } @@ -163,14 +246,18 @@ function stringify(typ: any): string { return `${typ}` } +export interface ParseResults { + errors: ValidationError[]; + warnings: ValidationError[]; +} // Main Schema export interface -export interface V0Schema { +export interface V0Schema extends ParseResults { version: Version; exports: SimpleExport[]; } -export interface V1Schema { +export interface V1Schema extends ParseResults { version: Version; exports: { [name: string]: Export }; imports?: { [name: string]: Import }; diff --git a/tests/index.test.ts b/tests/index.test.ts index fcc4da7..e219bc2 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -102,30 +102,30 @@ test('parse-v1-invalid-document', () => { expect(true).toBe('should have thrown') } catch (e) { const expectedErrors = [ - { - message: 'Invalid format date-time for type buffer. Valid formats are: []', - path: '#/exports/invalidFunc1/input' - }, - { - message: 'Invalid format float for type string. Valid formats are: [date-time, byte]', - path: '#/exports/invalidFunc1/output' - }, - { - message: 'Invalid format date-time for type boolean. Valid formats are: []', - path: '#/components/schemas/ComplexObject/properties/aBoolean' - }, - { - message: 'Invalid format int32 for type string. Valid formats are: [date-time, byte]', - path: '#/components/schemas/ComplexObject/properties/aString' - }, - { - message: 'Invalid format date-time for type integer. Valid formats are: [uint8, int8, uint16, int16, uint32, int32, uint64, int64]', - path: '#/components/schemas/ComplexObject/properties/anInt' - }, - { - message: "Invalid type 'non'. Options are: ['string', 'number', 'integer', 'boolean', 'object', 'array', 'buffer']", - path: '#/components/schemas/ComplexObject/properties/aNonType' - } + new ValidationError( + 'Invalid format date-time for type buffer. Valid formats are: []', + '#/exports/invalidFunc1/input' + ), + new ValidationError( + 'Invalid format float for type string. Valid formats are: [date-time, byte]', + '#/exports/invalidFunc1/output' + ), + new ValidationError( + 'Invalid format date-time for type boolean. Valid formats are: []', + '#/components/schemas/ComplexObject/properties/aBoolean' + ), + new ValidationError( + 'Invalid format int32 for type string. Valid formats are: [date-time, byte]', + '#/components/schemas/ComplexObject/properties/aString' + ), + new ValidationError( + 'Invalid format date-time for type integer. Valid formats are: [uint8, int8, uint16, int16, uint32, int32, uint64, int64]', + '#/components/schemas/ComplexObject/properties/anInt' + ), + new ValidationError( + "Invalid type 'non'. Options are: ['string', 'number', 'integer', 'boolean', 'object', 'array', 'buffer']", + '#/components/schemas/ComplexObject/properties/aNonType' + ), ] expectErrors(e, expectedErrors) @@ -139,26 +139,26 @@ test('parse-v1-invalid-ref-document', () => { expect(true).toBe('should have thrown') } catch (e) { const expectedErrors = [ - { - message: 'Invalid reference #/components/schemas/NonExistentExportInputRef. Cannot find schema NonExistentExportInputRef. Options are: [ComplexObject]', - path: '#/exports/invalidFunc/input/$ref' - }, - { - message: 'Invalid reference #/components/schemas/NonExistentImportOutputRef. Cannot find schema NonExistentImportOutputRef. Options are: [ComplexObject]', - path: '#/imports/invalidImport/output/$ref' - }, - { - message: 'Invalid reference #/components/schemas/NonExistentPropertyRef. Cannot find schema NonExistentPropertyRef. Options are: [ComplexObject]', - path: '#/components/schemas/ComplexObject/properties/invalidPropRef/$ref' - }, - { - message: 'Not a valid ref some invalid ref', - path: '#/exports/invalidFunc/output/$ref' - }, - { - message: "Property ghost is required but not defined", - path: "#/components/schemas/ComplexObject/required" - } + new ValidationError( + 'Property ghost is marked as required but not defined', + '#/components/schemas/ComplexObject' + ), + new ValidationError( + 'Invalid $ref "#/components/schemas/NonExistentExportInputRef". Cannot find schema "NonExistentExportInputRef". Options are: [ComplexObject]', + '#/exports/invalidFunc/input/$ref' + ), + new ValidationError( + 'Invalid $ref "#/components/schemas/NonExistentImportOutputRef". Cannot find schema "NonExistentImportOutputRef". Options are: [ComplexObject]', + '#/imports/invalidImport/output/$ref' + ), + new ValidationError( + 'Invalid $ref "#/components/schemas/NonExistentPropertyRef". Cannot find schema "NonExistentPropertyRef". Options are: [ComplexObject]', + '#/components/schemas/ComplexObject/properties/invalidPropRef/$ref' + ), + new ValidationError( + 'Invalid $ref "some invalid ref"', + '#/exports/invalidFunc/output/$ref' + ), ] expectErrors(e, expectedErrors) @@ -190,38 +190,38 @@ test('parse-v1-invalid-identifiers-doc', () => { expect(true).toBe('should have thrown') } catch (e) { const expectedErrors = [ - { - message: 'Invalid identifier: "Ghost)Gang". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', - path: '#/components/schemas/Ghost)Gang' - }, - { - message: 'Invalid identifier: "gh ost". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', - path: '#/components/schemas/ComplexObject/properties/gh ost' - }, - { - message: 'Invalid identifier: "aBoo{lean". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', - path: '#/components/schemas/ComplexObject/properties/aBoo{lean' - }, - { - message: 'Invalid identifier: "spooky ghost". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', - path: '#/components/schemas/Ghost)Gang/enum' - }, - { - message: 'Invalid identifier: "invalid@Func". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', - path: '#/exports/invalid@Func' - }, - { - message: 'Invalid identifier: "invalid invalid". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', - path: '#/exports/invalid invalid' - }, - { - message: 'Invalid identifier: "referenc/eTypeFunc". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', - path: '#/exports/referenc/eTypeFunc' - }, - { - message: 'Invalid identifier: "eatA:Fruit". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', - path: '#/imports/eatA:Fruit' - } + new ValidationError( + 'Invalid identifier: "Ghost)Gang". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', + '#/components/schemas/Ghost)Gang' + ), + new ValidationError( + 'Invalid identifier: "gh ost". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', + '#/components/schemas/ComplexObject/properties/gh ost' + ), + new ValidationError( + 'Invalid identifier: "aBoo{lean". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', + '#/components/schemas/ComplexObject/properties/aBoo{lean' + ), + new ValidationError( + 'Invalid identifier: "spooky ghost". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', + '#/components/schemas/Ghost)Gang/enum' + ), + new ValidationError( + 'Invalid identifier: "invalid@Func". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', + '#/exports/invalid@Func' + ), + new ValidationError( + 'Invalid identifier: "invalid invalid". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', + '#/exports/invalid invalid' + ), + new ValidationError( + 'Invalid identifier: "referenc/eTypeFunc". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', + '#/exports/referenc/eTypeFunc' + ), + new ValidationError( + 'Invalid identifier: "eatA:Fruit". Must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/', + '#/imports/eatA:Fruit' + ), ] expectErrors(e, expectedErrors) @@ -235,21 +235,86 @@ test('parse-v1-additional-props-doc', () => { expect(true).toBe('should have thrown') } catch (e) { const expectedErrors = [ - { - message: 'We currently do not support objects with both fixed properties and additionalProperties', - path: '#/components/schemas/MixedObject' - }, + new ValidationError( + 'We currently do not support objects with both fixed properties and additionalProperties', + '#/components/schemas/MixedObject' + ), + new ValidationError( + "The parent type must be 'object' when using additionalProperties but your type is undefined", + "#/components/schemas/MixedObject", + ), + new ValidationError( + "The parent type must be 'object' when using additionalProperties but your type is string", + "#/components/schemas/NonObjectType/properties/myMap", + ), ] expectErrors(e, expectedErrors) } }) +test('parse-v1-invalid-keyword-doc', () => { + const invalidV1Doc: any = yaml.load(fs.readFileSync('./tests/schemas/v1-invalid-keyword-doc.yaml', 'utf8')) + try { + const doc = parse(JSON.stringify(invalidV1Doc)) + const expectedErrors = [ + new ValidationError( + "Potentially Invalid identifier: \"Break\". This is a keyword in the following languages and may cause trouble with code generation: python,go,csharp,rust,cpp,zig", + "#/components/schemas/Break", + ), + new ValidationError( + "Potentially Invalid identifier: \"abstract\". This is a keyword in the following languages and may cause trouble with code generation: csharp,rust", + "#/components/schemas/Break/properties/abstract", + ), + new ValidationError( + "Potentially Invalid identifier: \"addrspace\". This is a keyword in the following languages and may cause trouble with code generation: zig", + "#/components/schemas/Break/properties/addrspace", + ), + new ValidationError( + "Potentially Invalid identifier: \"alignas\". This is a keyword in the following languages and may cause trouble with code generation: cpp", + "#/components/schemas/Break/properties/alignas", + ), + new ValidationError( + "Potentially Invalid identifier: \"and\". This is a keyword in the following languages and may cause trouble with code generation: python,cpp,zig", + "#/components/schemas/Break/properties/and", + ), + new ValidationError( + "Potentially Invalid identifier: \"as\". This is a keyword in the following languages and may cause trouble with code generation: python,csharp,rust", + "#/components/schemas/Break/properties/as", + ), + new ValidationError( + "Potentially Invalid identifier: \"interface\". This is a keyword in the following languages and may cause trouble with code generation: go,csharp", + "#/components/schemas/Break/properties/interface", + ), + new ValidationError( + "Potentially Invalid identifier: \"type\". This is a keyword in the following languages and may cause trouble with code generation: go,rust", + "#/components/schemas/Break/properties/type", + ), + new ValidationError( + "Potentially Invalid identifier: \"false\". This is a keyword in the following languages and may cause trouble with code generation: python,csharp,rust,cpp", + "#/exports/false", + ), + new ValidationError( + "Potentially Invalid identifier: \"true\". This is a keyword in the following languages and may cause trouble with code generation: python,csharp,rust,cpp", + "#/imports/true", + ), + ] + + + expectValidationErrors(doc.warnings, expectedErrors) + } catch (e) { + expect(true).toBe('should not have thrown') + } +}) + +function expectValidationErrors(given: ValidationError[], expected: ValidationError[]) { + const sortByPath = (a: ValidationError, b: ValidationError) => a.path.localeCompare(b.path); + expect([...given].sort(sortByPath)).toEqual([...expected].sort(sortByPath)); +} + function expectErrors(e: any, expectedErrors: ValidationError[]) { if (e instanceof NormalizeError) { - const sortByPath = (a: ValidationError, b: ValidationError) => a.path.localeCompare(b.path); - expect([...e.errors].sort(sortByPath)).toEqual([...expectedErrors].sort(sortByPath)); - + expectValidationErrors(e.errors, expectedErrors) return } diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 9c65509..c9f2868 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -6,18 +6,17 @@ const invalidV1Doc: any = yaml.load(fs.readFileSync('./tests/schemas/v1-invalid- const validV1Doc: any = yaml.load(fs.readFileSync('./tests/schemas/v1-valid-doc.yaml', 'utf8')) test("parse-empty-v1-document", () => { - const { errors } = parseAny({}) - expect(errors).toBeInstanceOf(Array) + const doc = parseAny({}) + expect(doc.errors).toBeInstanceOf(Array) - expect(errors![0].path).toEqual("#/version") + expect(doc.errors![0].path).toEqual("#/version") }) test("parse-invalid-v1-document", () => { - const { errors } = parseAny(invalidV1Doc) - expect(errors).toBeInstanceOf(Array) + const doc = parseAny(invalidV1Doc) + expect(doc.errors).toBeInstanceOf(Array) - const paths = errors!.map(e => e.path) - //console.log(JSON.stringify(errors!, null, 4)) + const paths = doc.errors!.map(e => e.path) expect(paths).toStrictEqual([ "#/exports/invalidFunc1/input", "#/exports/invalidFunc1/output", @@ -25,16 +24,13 @@ test("parse-invalid-v1-document", () => { "#/components/schemas/ComplexObject/properties/aString", "#/components/schemas/ComplexObject/properties/anInt", "#/components/schemas/ComplexObject/properties/aNonType", - // "#/components/schemas/ComplexObject/properties/aMapOfMapsOfNullableDateArrays/additionalProperties", - // "#/components/schemas/ComplexObject/properties/aMapOfMapsOfNullableDateArrays/additionalProperties", - // "#/components/schemas/ComplexObject/properties/aMapOfMapsOfNullableDateArrays/additionalProperties/additionalProperties", - // "#/components/schemas/ComplexObject/properties/anArrayOfMaps/items", ]) }) test("parse-valid-v1-document", () => { - const { doc, errors } = parseAny(validV1Doc) - expect(errors).toStrictEqual([]) + const doc = parseAny(validV1Doc) + expect(doc.errors).toStrictEqual([]) + expect(doc.warnings).toStrictEqual([]) const schema = doc as V1Schema diff --git a/tests/schemas/v1-invalid-additional-properties.yaml b/tests/schemas/v1-invalid-additional-properties.yaml index a1251cd..b93d335 100644 --- a/tests/schemas/v1-invalid-additional-properties.yaml +++ b/tests/schemas/v1-invalid-additional-properties.yaml @@ -1,9 +1,26 @@ +# yaml-language-server: $schema=https://xtp.dylibso.com/assets/wasm/schema.json --- version: v1-draft exports: hello: {} components: schemas: + ValidUse: + description: Valid use of additionalProperties + properties: + myMap: + type: object + additionalProperties: + type: string + NonObjectType: + description: should not additionalProperties if type !== object + properties: + hello: + type: string + myMap: + type: string + additionalProperties: + type: string MixedObject: description: should not allow mixing fixed and additional props for now properties: diff --git a/tests/schemas/v1-invalid-cycle-doc.yaml b/tests/schemas/v1-invalid-cycle-doc.yaml index 3aa6275..2ee41ff 100644 --- a/tests/schemas/v1-invalid-cycle-doc.yaml +++ b/tests/schemas/v1-invalid-cycle-doc.yaml @@ -1,9 +1,11 @@ +# yaml-language-server: $schema=https://xtp.dylibso.com/assets/wasm/schema.json --- version: v1-draft exports: export1: description: This is an export input: + contentType: application/json type: string output: contentType: application/json diff --git a/tests/schemas/v1-invalid-doc.yaml b/tests/schemas/v1-invalid-doc.yaml index 02177f4..478dd5a 100644 --- a/tests/schemas/v1-invalid-doc.yaml +++ b/tests/schemas/v1-invalid-doc.yaml @@ -1,12 +1,15 @@ +# yaml-language-server: $schema=https://xtp.dylibso.com/assets/wasm/schema.json --- version: v1-draft exports: invalidFunc1: description: Has some invalid parameters input: + contentType: application/x-binary type: buffer format: date-time output: + contentType: text/plain; charset=utf-8 type: string format: float components: diff --git a/tests/schemas/v1-invalid-identifier-doc.yaml b/tests/schemas/v1-invalid-identifier-doc.yaml index c909049..215446a 100644 --- a/tests/schemas/v1-invalid-identifier-doc.yaml +++ b/tests/schemas/v1-invalid-identifier-doc.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://xtp.dylibso.com/assets/wasm/schema.json --- version: v1-draft exports: @@ -64,4 +65,4 @@ components: description: I can override the description for the property here aBoo{lean: type: boolean - description: A boolean prop \ No newline at end of file + description: A boolean prop diff --git a/tests/schemas/v1-invalid-keyword-doc.yaml b/tests/schemas/v1-invalid-keyword-doc.yaml new file mode 100644 index 0000000..9c4ee83 --- /dev/null +++ b/tests/schemas/v1-invalid-keyword-doc.yaml @@ -0,0 +1,31 @@ +# yaml-language-server: $schema=https://xtp.dylibso.com/assets/wasm/schema.json +--- +version: v1-draft +exports: + "false": + description: | + This demonstrates how you can create an export with + no inputs or outputs. +imports: + "true": + description: | + This is a host function. +components: + schemas: + Break: + description: A complex json object + properties: + addrspace: + type: string + alignas: + type: string + abstract: + type: string + as: + type: string + and: + type: string + interface: + type: string + type: + type: string diff --git a/tests/schemas/v1-invalid-ref-doc.yaml b/tests/schemas/v1-invalid-ref-doc.yaml index 31e6396..a36998e 100644 --- a/tests/schemas/v1-invalid-ref-doc.yaml +++ b/tests/schemas/v1-invalid-ref-doc.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://xtp.dylibso.com/assets/wasm/schema.json --- version: v1-draft exports: @@ -15,6 +16,7 @@ imports: This is a host function. Right now host functions can only be the type (i64) -> i64. We will support more in the future. Much of the same rules as exports apply. input: + contentType: text/plain; charset=utf-8 type: string output: contentType: application/json diff --git a/tests/schemas/v1-valid-doc.yaml b/tests/schemas/v1-valid-doc.yaml index b69f278..7cdf9d6 100644 --- a/tests/schemas/v1-valid-doc.yaml +++ b/tests/schemas/v1-valid-doc.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://xtp.dylibso.com/assets/wasm/schema.json --- version: v1-draft exports: @@ -92,6 +93,7 @@ components: nullable: true aMap: description: a string map + type: object additionalProperties: type: string anIntRef: @@ -99,7 +101,9 @@ components: $ref: "#/components/schemas/MyInt" aMapOfMapsOfNullableDateArrays: description: a weird map, it's too deep to cast correctly right now + type: object additionalProperties: + type: object additionalProperties: items: nullable: true @@ -132,7 +136,7 @@ components: type: integer format: uint64 description: An uint64 prop - type: + kind: type: string description: An enum prop enum: [complex, simple] @@ -150,6 +154,7 @@ components: description: an int as a schema type: integer MapSchema: + type: object additionalProperties: type: string