Skip to content

Commit

Permalink
Implement inline object schema validator (#282)
Browse files Browse the repository at this point in the history
* Implement inline object schema validator and underlying visitor pattern

Signed-off-by: Thomas Farr <[email protected]>

* Fix spec lint error

Signed-off-by: Thomas Farr <[email protected]>

---------

Signed-off-by: Thomas Farr <[email protected]>
  • Loading branch information
Xtansia committed May 6, 2024
1 parent 87e0693 commit 909b02d
Show file tree
Hide file tree
Showing 8 changed files with 244 additions and 15 deletions.
13 changes: 1 addition & 12 deletions spec/namespaces/notifications.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -254,18 +254,7 @@ components:
channel_list:
type: array
items:
type: object
properties:
config_id:
type: string
name:
type: string
description:
type: string
config_type:
$ref: '../schemas/notifications._common.yaml#/components/schemas/NotificationConfigType'
is_enabled:
type: boolean
$ref: '../schemas/notifications._common.yaml#/components/schemas/NotificationChannel'
notifications.list_features@200:
description: ''
content:
Expand Down
13 changes: 13 additions & 0 deletions spec/schemas/notifications._common.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,16 @@ components:
type: object
additionalProperties:
type: string
NotificationChannel:
type: object
properties:
config_id:
type: string
name:
type: string
description:
type: string
config_type:
$ref: '#/components/schemas/NotificationConfigType'
is_enabled:
type: boolean
2 changes: 1 addition & 1 deletion tools/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default [
'@typescript-eslint/dot-notation': 'error',
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/naming-convention': ['error',
{ selector: 'classProperty', modifiers: ['readonly'], format: ['UPPER_CASE'], leadingUnderscore: 'allow' },
{ selector: 'classProperty', modifiers: ['static', 'readonly'], format: ['UPPER_CASE'], leadingUnderscore: 'allow' },
{ selector: 'memberLike', modifiers: ['public'], format: ['snake_case'], leadingUnderscore: 'forbid' },
{ selector: 'memberLike', modifiers: ['private', 'protected'], format: ['snake_case'], leadingUnderscore: 'require' },
{ selector: 'variableLike', format: ['snake_case', 'UPPER_CASE'], leadingUnderscore: 'allow' },
Expand Down
44 changes: 44 additions & 0 deletions tools/linter/InlineObjectSchemaValidator.ts
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'))
}
}
}
6 changes: 5 additions & 1 deletion tools/linter/SpecValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,23 @@ import { type ValidationError } from '../types'
import SchemaRefsValidator from './SchemaRefsValidator'
import SupersededOperationsFile from './components/SupersededOperationsFile'
import InfoFile from './components/InfoFile'
import InlineObjectSchemaValidator from './InlineObjectSchemaValidator'

export default class SpecValidator {
superseded_ops_file: SupersededOperationsFile
info_file: InfoFile
namespaces_folder: NamespacesFolder
schemas_folder: SchemasFolder
schema_refs_validator: SchemaRefsValidator
inline_object_schema_validator: InlineObjectSchemaValidator

constructor (root_folder: string) {
this.superseded_ops_file = new SupersededOperationsFile(`${root_folder}/_superseded_operations.yaml`)
this.info_file = new InfoFile(`${root_folder}/_info.yaml`)
this.namespaces_folder = new NamespacesFolder(`${root_folder}/namespaces`)
this.schemas_folder = new SchemasFolder(`${root_folder}/schemas`)
this.schema_refs_validator = new SchemaRefsValidator(this.namespaces_folder, this.schemas_folder)
this.inline_object_schema_validator = new InlineObjectSchemaValidator(this.namespaces_folder, this.schemas_folder)
}

validate (): ValidationError[] {
Expand All @@ -30,7 +33,8 @@ export default class SpecValidator {
return [
...this.schema_refs_validator.validate(),
...this.superseded_ops_file.validate(),
...this.info_file.validate()
...this.info_file.validate(),
...this.inline_object_schema_validator.validate()
]
}
}
2 changes: 1 addition & 1 deletion tools/linter/components/OperationGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type ValidationError } from '../../types'
import ValidatorBase from './base/ValidatorBase'

export default class OperationGroup extends ValidatorBase {
readonly OP_PRIORITY = ['operationId', 'x-operation-group', 'x-ignorable', 'deprecated',
static readonly OP_PRIORITY = ['operationId', 'x-operation-group', 'x-ignorable', 'deprecated',
'x-deprecation-message', 'x-version-added', 'x-version-deprecated', 'x-version-removed',
'description', 'externalDocs', 'parameters', 'requestBody', 'responses']

Expand Down
117 changes: 117 additions & 0 deletions tools/linter/utils/SpecificationVisitor.ts
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)
}
}
62 changes: 62 additions & 0 deletions tools/linter/utils/index.ts
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]

0 comments on commit 909b02d

Please sign in to comment.