Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement inline object schema validator #282

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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]
Loading