diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 0000000..5488b7a --- /dev/null +++ b/src/common.ts @@ -0,0 +1,6 @@ +export class ValidationError extends Error { + constructor(public message: string, public path: string) { + super(message); + Object.setPrototypeOf(this, ValidationError.prototype); + } +} diff --git a/src/index.ts b/src/index.ts index 97281a6..0fb1ac1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { } from "./normalizer"; import { CodeSample } from "./parser"; export * from "./normalizer"; +export { ValidationError } from "./common"; export function parse(schema: string) { return parseAndNormalizeJson(schema); diff --git a/src/normalizer.ts b/src/normalizer.ts index 9416871..5f24052 100644 --- a/src/normalizer.ts +++ b/src/normalizer.ts @@ -1,3 +1,4 @@ +import { ValidationError } from "./common"; import * as parser from "./parser" export interface XtpItemType extends Omit { @@ -53,17 +54,9 @@ export function isExport(e: any): e is Export { // These are the same for now export type Import = Export -class NormalizerError extends Error { - constructor(m: string) { - super(m); - Object.setPrototypeOf(this, NormalizerError.prototype); - } -} - function normalizeV0Schema(parsed: parser.V0Schema): XtpSchema { const version = 'v0' const exports: Export[] = [] - // we don't have any schemas or imports const imports: Import[] = [] const schemas = {} @@ -81,22 +74,31 @@ function normalizeV0Schema(parsed: parser.V0Schema): XtpSchema { } } -function parseSchemaRef(ref: string): string { +function querySchemaRef(schemas: { [key: string]: Schema }, ref: string, location: string): Schema { const parts = ref.split('/') - if (parts[0] !== '#') throw Error("Not a valid ref " + ref) - if (parts[1] !== 'components') throw Error("Not a valid ref " + ref) - if (parts[2] !== 'schemas') throw Error("Not a valid ref " + ref) - return parts[3] + if (parts[0] !== '#') throw new ValidationError("Not a valid ref " + ref, location); + if (parts[1] !== 'components') throw new ValidationError("Not a valid ref " + ref, location); + if (parts[2] !== 'schemas') throw new ValidationError("Not a valid ref " + ref, location); + const name = parts[3]; + + const s = schemas[name] + if (!s) { + const availableSchemas = Object.keys(schemas).join(', ') + throw new ValidationError(`invalid reference ${ref}. Cannot find schema ${name}. Options are: ${availableSchemas}`, location); + } + return s } -function normalizeProp(p: Parameter | Property | XtpItemType, s: Schema) { +function normalizeProp(p: Parameter | Property | XtpItemType | parser.XtpItemType, s: Schema, location: string) { p.$ref = s p.description = p.description || s.description // double ensure that content types are lowercase if ('contentType' in p) { p.contentType = p.contentType.toLowerCase() as MimeType } - if (!p.type) p.type = 'string' + if (!p.type) { + p.type = 'string' + } if (s.type) { // if it's not an object assume it's a string if (s.type === 'object') { @@ -105,6 +107,38 @@ function normalizeProp(p: Parameter | Property | XtpItemType, s: Schema) { } } +function validateArrayItems(arrayItem: XtpItemType | parser.XtpItemType | undefined, location: string): void { + if (!arrayItem || !arrayItem.type) { + return; + } + + validateTypeAndFormat(arrayItem.type, arrayItem.format, location); +} + +function validateTypeAndFormat(type: XtpType, format: XtpFormat | undefined, location: string): void { + const validTypes = ['string', 'number', 'integer', 'boolean', 'object', 'array']; + if (!validTypes.includes(type)) { + throw new ValidationError(`Invalid type '${type}'. Options are: ${validTypes.map(t => `'${t}'`).join(', ')}`, location); + } + + if (!format) { + return; + } + + let validFormats: XtpFormat[] = []; + if (type === 'string') { + validFormats = ['date-time', 'byte']; + } else if (type === 'number') { + validFormats = ['float', 'double']; + } else if (type === 'integer') { + validFormats = ['int32', 'int64']; + } + + if (!validFormats.includes(format)) { + throw new ValidationError(`Invalid format ${format} for type ${type}. Valid formats are: ${validFormats.join(', ')}`, location); + } +} + function normalizeV1Schema(parsed: parser.V1Schema): XtpSchema { const version = 'v1' const exports: Export[] = [] @@ -119,6 +153,10 @@ function normalizeV1Schema(parsed: parser.V1Schema): XtpSchema { const p = s.properties[pName] as Property p.name = pName properties.push(p) + + if (p.items?.$ref) { + validateArrayItems(p.items, `#/components/schemas/${name}/properties/${pName}/items`); + } } // overwrite the name @@ -138,22 +176,29 @@ function normalizeV1Schema(parsed: parser.V1Schema): XtpSchema { // link the property with a reference to the schema if it has a ref // need to get the ref from the parsed (raw) property const rawProp = parsed.components!.schemas![name].properties![p.name] + const propPath = `#/components/schemas/${name}/properties/${p.name}`; if (rawProp.$ref) { normalizeProp( schemas[name].properties[idx], - schemas[parseSchemaRef(rawProp.$ref)] + querySchemaRef(schemas, rawProp.$ref, propPath), + propPath ) } if (rawProp.items?.$ref) { + const path = `${propPath}/items` + normalizeProp( - //@ts-ignore p.items!, - schemas[parseSchemaRef(rawProp.items!.$ref)] + querySchemaRef(schemas, rawProp.items!.$ref, path), + path ) } + validateTypeAndFormat(p.type, p.format, propPath); + validateArrayItems(p.items, `${propPath}/items`); + // coerce to false by default p.nullable = p.nullable || false }) @@ -164,45 +209,56 @@ function normalizeV1Schema(parsed: parser.V1Schema): XtpSchema { let ex = parsed.exports[name] if (parser.isComplexExport(ex)) { - // they have the same type - // deref input and output const normEx = ex as Export normEx.name = name if (ex.input?.$ref) { + const path = `#/exports/${name}/input` + normalizeProp( normEx.input!, - schemas[parseSchemaRef(ex.input.$ref)] + querySchemaRef(schemas, ex.input.$ref, path), + path ) } if (ex.input?.items?.$ref) { + const path = `#/exports/${name}/input/items` + normalizeProp( - //@ts-ignore - normEx.input.items!, - schemas[parseSchemaRef(ex.input.items.$ref)] + normEx.input!.items!, + querySchemaRef(schemas, ex.input.items.$ref, path), + path ) } if (ex.output?.$ref) { + const path = `#/exports/${name}/output` + normalizeProp( normEx.output!, - schemas[parseSchemaRef(ex.output.$ref)] + querySchemaRef(schemas, ex.output.$ref, path), + path ) } if (ex.output?.items?.$ref) { + const path = `#/exports/${name}/output/items` + normalizeProp( - // @ts-ignore - normEx.output.items!, - schemas[parseSchemaRef(ex.output.items.$ref)] + normEx.output!.items!, + querySchemaRef(schemas, ex.output.items.$ref, path), + path ) } + validateArrayItems(normEx.input?.items, `#/exports/${name}/input/items`); + validateArrayItems(normEx.output?.items, `#/exports/${name}/output/items`); + exports.push(normEx) } else if (parser.isSimpleExport(ex)) { // it's just a name exports.push({ name }) } else { - throw new NormalizerError("Unable to match export to a simple or a complex export") + throw new ValidationError("Unable to match export to a simple or a complex export", `#/exports/${name}`); } } @@ -216,36 +272,57 @@ function normalizeV1Schema(parsed: parser.V1Schema): XtpSchema { // deref input and output if (im.input?.$ref) { + const path = `#/imports/${name}/input` + normalizeProp( normIm.input!, - schemas[parseSchemaRef(im.input.$ref)] + querySchemaRef(schemas, im.input.$ref, path), + path ) } if (im.input?.items?.$ref) { + const path = `#/imports/${name}/input/items` + normalizeProp( - // @ts-ignore - normIm.input.items!, - schemas[parseSchemaRef(im.input.items.$ref)] + normIm.input!.items!, + querySchemaRef(schemas, im.input.items.$ref, path), + path ) } if (im.output?.$ref) { + const path = `#/imports/${name}/output` + normalizeProp( normIm.output!, - schemas[parseSchemaRef(im.output.$ref)] + querySchemaRef(schemas, im.output.$ref, path), + path ) } if (im.output?.items?.$ref) { + const path = `#/imports/${name}/output/items` + normalizeProp( - // @ts-ignore - normIm.output.items!, - schemas[parseSchemaRef(im.output.items.$ref)] + normIm.output!.items!, + querySchemaRef(schemas, im.output.items.$ref, path), + path ) } + validateArrayItems(normIm.input?.items, `#/imports/${name}/input/items`); + validateArrayItems(normIm.output?.items, `#/imports/${name}/output/items`); + imports.push(normIm) } + for (const name in schemas) { + const schema = schemas[name] + const error = detectCircularReference(schema); + if (error) { + throw error; + } + } + return { version, exports, @@ -262,7 +339,30 @@ export function parseAndNormalizeJson(encoded: string): XtpSchema { } else if (parser.isV1Schema(parsed)) { return normalizeV1Schema(parsed) } else { - throw new NormalizerError("Could not normalized unknown version of schema") + throw new ValidationError("Could not normalize unknown version of schema", "#"); } } +function detectCircularReference(schema: Schema, visited: Set = new Set()): ValidationError | null { + if (visited.has(schema.name)) { + return new ValidationError("Circular reference detected", `#/components/schemas/${schema.name}`); + } + + visited.add(schema.name); + + for (const property of schema.properties) { + if (property.$ref) { + const error = detectCircularReference(property.$ref, new Set(visited)); + if (error) { + return error; + } + } else if (property.items?.$ref) { + const error = detectCircularReference(property.items.$ref, new Set(visited)); + if (error) { + return error; + } + } + } + + return null; +} \ No newline at end of file diff --git a/src/parser.ts b/src/parser.ts index 88b384f..1058a3b 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,3 +1,5 @@ +import { ValidationError } from "./common"; + // Main Schema export interface export interface V0Schema { version: Version; @@ -27,7 +29,7 @@ export function isComplexExport(exportItem: Export): exportItem is ComplexExport } export function isSimpleExport(exportItem: Export): exportItem is SimpleExport { - return typeof exportItem === 'string'; + return typeof exportItem === 'object'; } export type SimpleExport = string; @@ -63,6 +65,7 @@ export type XtpFormat = export interface XtpItemType { type: XtpType; + format?: XtpFormat; // NOTE: needs to be any to satisfy type satisfy // type system in normalizer "$ref"?: any; @@ -83,28 +86,26 @@ export interface Property { description?: string; nullable?: boolean; - // NOTE: needs to be any to satisfy type satisfy - // type system in normalizer + // NOTE: needs to be any to satisfy type safity in normalizer "$ref"?: any; } -class ParseError extends Error { - constructor(m: string) { - super(m); - Object.setPrototypeOf(this, ParseError.prototype); - } -} - export function parseJson(encoded: string): VUnknownSchema { - let parsed = JSON.parse(encoded) - if (!parsed.version) throw new ParseError("version property missing") + let parsed: any; + try { + parsed = JSON.parse(encoded); + } catch (e) { + throw new ValidationError("Invalid JSON", "#"); + } + + if (!parsed.version) throw new ValidationError("version property missing", "#"); switch (parsed.version) { case 'v0': - return parsed as V0Schema + return parsed as V0Schema; case 'v1-draft': - return parsed as V1Schema + return parsed as V1Schema; default: - throw new ParseError(`version property not valid: ${parsed.version}`) + throw new ValidationError(`version property not valid: ${parsed.version}`, "#/version"); } } @@ -114,6 +115,4 @@ export function isV0Schema(schema: VUnknownSchema): schema is V0Schema { export function isV1Schema(schema: VUnknownSchema): schema is V1Schema { return schema.version === 'v1-draft'; -} - - +} \ No newline at end of file