-
Notifications
You must be signed in to change notification settings - Fork 68
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement inline object schema validator (#282)
* Implement inline object schema validator and underlying visitor pattern Signed-off-by: Thomas Farr <tsfarr@amazon.com> * Fix spec lint error Signed-off-by: Thomas Farr <tsfarr@amazon.com> --------- Signed-off-by: Thomas Farr <tsfarr@amazon.com>
- Loading branch information
Showing
8 changed files
with
244 additions
and
15 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import type NamespacesFolder from './components/NamespacesFolder' | ||
import type SchemasFolder from './components/SchemasFolder' | ||
import { type ValidationError } from '../types' | ||
import { SchemaVisitor } from './utils/SpecificationVisitor' | ||
import { is_ref, type MaybeRef, SpecificationContext } from './utils' | ||
import { type OpenAPIV3 } from 'openapi-types' | ||
|
||
export default class InlineObjectSchemaValidator { | ||
private readonly _namespaces_folder: NamespacesFolder | ||
private readonly _schemas_folder: SchemasFolder | ||
|
||
constructor (namespaces_folder: NamespacesFolder, schemas_folder: SchemasFolder) { | ||
this._namespaces_folder = namespaces_folder | ||
this._schemas_folder = schemas_folder | ||
} | ||
|
||
validate (): ValidationError[] { | ||
const errors: ValidationError[] = [] | ||
|
||
const visitor = new SchemaVisitor((ctx, schema) => { | ||
this.#validate_schema(ctx, schema, errors) | ||
}); | ||
|
||
[ | ||
...this._namespaces_folder.files, | ||
...this._schemas_folder.files | ||
].forEach(f => { visitor.visit_specification(new SpecificationContext(f.file), f.spec()) }) | ||
|
||
return errors | ||
} | ||
|
||
#validate_schema (ctx: SpecificationContext, schema: MaybeRef<OpenAPIV3.SchemaObject>, errors: ValidationError[]): void { | ||
if (is_ref(schema) || schema.type !== 'object' || schema.properties === undefined) { | ||
return | ||
} | ||
|
||
const this_key = ctx.key | ||
const parent_key = ctx.parent().key | ||
|
||
if (parent_key === 'properties' || this_key === 'additionalProperties' || this_key === 'items') { | ||
errors.push(ctx.error('object schemas should be defined out-of-line via a $ref')) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
import { is_array_schema, is_ref, type KeysMatching, type MaybeRef, type SpecificationContext } from './index' | ||
import { OpenAPIV3 } from 'openapi-types' | ||
|
||
type VisitorCallback<T> = (ctx: SpecificationContext, o: NonNullable<T>) => void | ||
type SchemaVisitorCallback = VisitorCallback<MaybeRef<OpenAPIV3.SchemaObject>> | ||
|
||
function visit<Parent, Key extends keyof Parent> ( | ||
ctx: SpecificationContext, | ||
parent: Parent, | ||
key: Key, | ||
visitor: VisitorCallback<Parent[Key]> | ||
): void { | ||
const child = parent[key] | ||
if (child == null) return | ||
visitor(ctx.child(key as string), child) | ||
} | ||
|
||
type EnumerableKeys<T extends object> = KeysMatching<T, Record<string, unknown> | undefined> | KeysMatching<T, ArrayLike<unknown> | undefined> | ||
type ElementOf<T> = T extends Record<string, infer V> ? V : T extends ArrayLike<infer V> ? V : never | ||
|
||
function visit_each<Parent extends object, Key extends EnumerableKeys<Parent>> ( | ||
ctx: SpecificationContext, | ||
parent: Parent, | ||
key: Key, | ||
visitor: VisitorCallback<ElementOf<Parent[Key]>> | ||
): void { | ||
const children = parent[key] | ||
if (children == null) return | ||
ctx = ctx.child(key as string) | ||
Object.entries<ElementOf<Parent[Key]>>(children).forEach(([key, child]) => { | ||
if (child == null) return | ||
visitor(ctx.child(key), child) | ||
}) | ||
} | ||
|
||
export class SpecificationVisitor { | ||
visit_specification (ctx: SpecificationContext, specification: OpenAPIV3.Document): void { | ||
visit_each(ctx, specification, 'paths', this.visit_path.bind(this)) | ||
visit(ctx, specification, 'components', this.visit_components.bind(this)) | ||
} | ||
|
||
visit_path (ctx: SpecificationContext, path: OpenAPIV3.PathItemObject): void { | ||
visit_each(ctx, path, 'parameters', this.visit_parameter.bind(this)) | ||
|
||
for (const method of Object.values(OpenAPIV3.HttpMethods)) { | ||
visit(ctx, path, method, this.visit_operation.bind(this)) | ||
} | ||
} | ||
|
||
visit_operation (ctx: SpecificationContext, operation: OpenAPIV3.OperationObject): void { | ||
visit_each(ctx, operation, 'parameters', this.visit_parameter.bind(this)) | ||
visit(ctx, operation, 'requestBody', this.visit_request_body.bind(this)) | ||
visit_each(ctx, operation, 'responses', this.visit_response.bind(this)) | ||
} | ||
|
||
visit_components (ctx: SpecificationContext, components: OpenAPIV3.ComponentsObject): void { | ||
visit_each(ctx, components, 'parameters', this.visit_parameter.bind(this)) | ||
visit_each(ctx, components, 'requestBodies', this.visit_request_body.bind(this)) | ||
visit_each(ctx, components, 'responses', this.visit_response.bind(this)) | ||
visit_each(ctx, components, 'schemas', this.visit_schema.bind(this)) | ||
} | ||
|
||
visit_parameter (ctx: SpecificationContext, parameter: MaybeRef<OpenAPIV3.ParameterObject>): void { | ||
if (is_ref(parameter)) return | ||
|
||
visit(ctx, parameter, 'schema', this.visit_schema.bind(this)) | ||
} | ||
|
||
visit_request_body (ctx: SpecificationContext, request_body: MaybeRef<OpenAPIV3.RequestBodyObject>): void { | ||
if (is_ref(request_body)) return | ||
|
||
visit_each(ctx, request_body, 'content', this.visit_media_type.bind(this)) | ||
} | ||
|
||
visit_response (ctx: SpecificationContext, response: MaybeRef<OpenAPIV3.ResponseObject>): void { | ||
if (is_ref(response)) return | ||
|
||
visit_each(ctx, response, 'content', this.visit_media_type.bind(this)) | ||
} | ||
|
||
visit_media_type (ctx: SpecificationContext, media_type: OpenAPIV3.MediaTypeObject): void { | ||
visit(ctx, media_type, 'schema', this.visit_schema.bind(this)) | ||
} | ||
|
||
visit_schema (ctx: SpecificationContext, schema: MaybeRef<OpenAPIV3.SchemaObject>): void { | ||
if (is_ref(schema)) return | ||
|
||
if (is_array_schema(schema)) { | ||
visit(ctx, schema, 'items', this.visit_schema.bind(this)) | ||
} | ||
|
||
visit(ctx, schema, 'additionalProperties', (ctx, v) => { | ||
if (typeof v !== 'object') return | ||
this.visit_schema(ctx, v) | ||
}) | ||
|
||
visit_each(ctx, schema, 'properties', this.visit_schema.bind(this)) | ||
visit_each(ctx, schema, 'allOf', this.visit_schema.bind(this)) | ||
visit_each(ctx, schema, 'anyOf', this.visit_schema.bind(this)) | ||
visit_each(ctx, schema, 'oneOf', this.visit_schema.bind(this)) | ||
visit(ctx, schema, 'not', this.visit_schema.bind(this)) | ||
} | ||
} | ||
|
||
export class SchemaVisitor extends SpecificationVisitor { | ||
private readonly _callback: SchemaVisitorCallback | ||
|
||
constructor (callback: SchemaVisitorCallback) { | ||
super() | ||
this._callback = callback | ||
} | ||
|
||
visit_schema (ctx: SpecificationContext, schema: MaybeRef<OpenAPIV3.SchemaObject>): void { | ||
super.visit_schema(ctx, schema) | ||
this._callback(ctx, schema) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import { type OpenAPIV3 } from 'openapi-types' | ||
import { type ValidationError } from '../../types' | ||
|
||
export function is_ref<O extends object> (o: MaybeRef<O>): o is OpenAPIV3.ReferenceObject { | ||
return '$ref' in o | ||
} | ||
|
||
export function is_array_schema (schema: OpenAPIV3.SchemaObject): schema is OpenAPIV3.ArraySchemaObject { | ||
return schema.type === 'array' | ||
} | ||
|
||
export function is_primitive_schema (schema: OpenAPIV3.SchemaObject): boolean { | ||
return schema.type === 'boolean' || | ||
schema.type === 'integer' || | ||
schema.type === 'number' || | ||
schema.type === 'string' | ||
} | ||
|
||
export class SpecificationContext { | ||
private readonly _file: string | ||
private readonly _location: string[] | ||
|
||
constructor (file: string, location?: string[]) { | ||
this._file = file | ||
this._location = location ?? ['#'] | ||
} | ||
|
||
parent (): SpecificationContext { | ||
if (this._location.length <= 1) return this | ||
return new SpecificationContext(this._file, this._location.slice(0, -1)) | ||
} | ||
|
||
child (child: string): SpecificationContext { | ||
return new SpecificationContext(this._file, [...this._location, child]) | ||
} | ||
|
||
error (message: string): ValidationError { | ||
return { file: this._file, location: this.location, message } | ||
} | ||
|
||
get file (): string { | ||
return this._file | ||
} | ||
|
||
get location (): string { | ||
return this._location | ||
.map(k => k | ||
.replaceAll('~', '~0') | ||
.replaceAll('/', '~1')) | ||
.join('/') | ||
} | ||
|
||
get key (): string { | ||
return this._location[this._location.length - 1] | ||
} | ||
} | ||
|
||
export type MaybeRef<O extends object> = O | OpenAPIV3.ReferenceObject | ||
|
||
export type KeysMatching<T extends object, V> = { | ||
[K in keyof T]-?: T[K] extends V ? K : never | ||
}[keyof T] |