1
0
mirror of synced 2025-12-25 02:17:36 -05:00

Consolidate uses of AJV (#47662)

This commit is contained in:
Rachael Sewell
2023-12-13 11:58:43 -08:00
committed by GitHub
parent d505488eba
commit 97c70307b6
17 changed files with 138 additions and 149 deletions

View File

@@ -1,10 +1,8 @@
import { jest } from '@jest/globals'
import Ajv from 'ajv'
import addErrors from 'ajv-errors'
import semver from 'semver'
import featureVersionsSchema from '../lib/feature-versions-schema.js'
import { getDeepDataByLanguage } from '#src/data-directory/lib/get-data.js'
import { getJsonValidator } from '#src/tests/lib/validate-json-schema.js'
import { formatAjvErrors } from '#src/tests/helpers/schemas.js'
/*
@@ -18,26 +16,18 @@ import { formatAjvErrors } from '#src/tests/helpers/schemas.js'
jest.useFakeTimers({ legacyFakeTimers: true })
const featureVersions = Object.entries(getDeepDataByLanguage('features', 'en'))
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true })
addErrors(ajv)
// *** TODO: We can drop this override once the frontmatter schema has been updated to work with AJV. ***
ajv.addFormat('semver', {
validate: (x) => semver.validRange(x),
})
// *** End TODO ***
const validate = ajv.compile(featureVersionsSchema)
const validate = getJsonValidator(featureVersionsSchema)
// Make sure data/features/*.yml contains valid versioning.
describe('lint feature versions', () => {
test.each(featureVersions)('data/features/%s matches the schema', (name, featureVersion) => {
const valid = validate(featureVersion)
const isValid = validate(featureVersion)
let errors
if (!valid) {
if (!isValid) {
errors = formatAjvErrors(validate.errors)
}
expect(valid, errors).toBe(true)
expect(isValid, errors).toBe(true)
})
})

View File

@@ -1,23 +1,19 @@
import express from 'express'
import { omit, without, mapValues } from 'lodash-es'
import Ajv from 'ajv'
import addFormats from 'ajv-formats'
import QuickLRU from 'quick-lru'
import { schemas, hydroNames } from './lib/schema.js'
import catchMiddlewareError from '#src/observability/middleware/catch-middleware-error.js'
import { noCacheControl } from '#src/frame/middleware/cache-control.js'
import { getJsonValidator } from '#src/tests/lib/validate-json-schema.js'
import { formatErrors } from './lib/middleware-errors.js'
import { publish as _publish } from './lib/hydro.js'
const router = express.Router()
const ajv = new Ajv()
addFormats(ajv)
const OMIT_FIELDS = ['type']
const allowedTypes = new Set(without(Object.keys(schemas), 'validation'))
const isProd = process.env.NODE_ENV === 'production'
const validations = mapValues(schemas, (schema) => ajv.compile(schema))
const validators = mapValues(schemas, (schema) => getJsonValidator(schema))
// In production, fire and not wait to respond.
// _publish will send an error to failbot,
// so we don't get alerts but we still track it.
@@ -47,7 +43,7 @@ router.post(
}
// Validate the data matches the corresponding data schema
const validate = validations[type]
const validate = validators[type]
if (!validate(req.body)) {
const hash = `${req.ip}:${validate.errors
.map((error) => error.message + error.instancePath)

View File

@@ -1,17 +1,13 @@
import Ajv from 'ajv'
import addFormats from 'ajv-formats'
import { validateJson } from '#src/tests/lib/validate-json-schema.js'
import { formatErrors } from '../lib/middleware-errors.js'
import { schemas } from '../lib/schema.js'
const ajv = new Ajv()
addFormats(ajv)
expect.extend({
toMatchSchema(data, schema) {
const isValid = ajv.validate(schema, data)
const { isValid, errors } = validateJson(schema, data)
return {
pass: isValid,
message: () => (isValid ? '' : ajv.errorsText()),
message: () => (isValid ? '' : errors.message),
}
},
})
@@ -19,8 +15,9 @@ expect.extend({
describe('formatErrors', () => {
it('should produce objects that match the validation spec', () => {
// Produce an error
ajv.validate({ type: 'string' }, 0)
for (const formatted of formatErrors(ajv.errors, '')) {
const { errors } = validateJson({ type: 'string' }, 0)
const formattedErrors = formatErrors(errors, '')
for (const formatted of formattedErrors) {
expect(formatted).toMatchSchema(schemas.validation)
}
})

View File

@@ -1,25 +1,12 @@
import matter from 'gray-matter'
import Ajv from 'ajv'
import addErrors from 'ajv-errors'
import addFormats from 'ajv-formats'
import semver from 'semver'
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true })
ajv.addKeyword({
keyword: 'translatable',
})
ajv.addFormat('semver', {
validate: (x) => semver.validRange(x),
})
addErrors(ajv)
addFormats(ajv)
import { validateJson } from '#src/tests/lib/validate-json-schema.js'
function readFrontmatter(markdown, opts = {}) {
const schema = opts.schema || { type: 'object', properties: {} }
const filepath = opts.filepath || null
let content, data
let errors = []
try {
;({ content, data } = matter(markdown))
@@ -39,18 +26,13 @@ function readFrontmatter(markdown, opts = {}) {
}
if (filepath) error.filepath = filepath
errors.push(error)
const errors = [error]
console.warn(errors)
return { errors }
}
const ajvValidate = ajv.compile(schema)
const valid = ajvValidate(data)
if (!valid) {
errors = ajvValidate.errors
}
const validate = validateJson(schema, data)
// Combine the AJV-supplied `instancePath` and `params` into a more user-friendly frontmatter path.
// For example, given:
@@ -69,8 +51,10 @@ function readFrontmatter(markdown, opts = {}) {
return typeof mainProps !== 'object' ? `${prefixProps}.${mainProps}` : prefixProps
}
if (!valid && filepath) {
errors = ajvValidate.errors.map((error) => {
const errors = []
if (!validate.isValid && filepath) {
const formattedErrors = validate.errors.map((error) => {
const userFriendly = {}
userFriendly.property = cleanPropertyPath(error.params, error.instancePath)
userFriendly.message = error.message
@@ -78,6 +62,9 @@ function readFrontmatter(markdown, opts = {}) {
userFriendly.filepath = filepath
return userFriendly
})
errors.push(...formattedErrors)
} else if (!validate.isValid) {
errors.push(...validate.errors)
}
return { content, data, errors }

View File

@@ -1,5 +1,6 @@
import Ajv from 'ajv'
import { jest } from '@jest/globals'
import { getJsonValidator } from '#src/tests/lib/validate-json-schema.js'
import schema from '#src/tests/helpers/schemas/site-tree-schema.js'
import EnterpriseServerReleases from '#src/versions/lib/enterprise-server-releases.js'
import { loadSiteTree } from '#src/frame/lib/page-data.js'
@@ -8,8 +9,7 @@ import { formatAjvErrors } from '#src/tests/helpers/schemas.js'
const latestEnterpriseRelease = EnterpriseServerReleases.latest
const ajv = new Ajv({ allErrors: true })
const siteTreeValidate = ajv.compile(schema.childPage)
const siteTreeValidate = getJsonValidator(schema.childPage)
describe('siteTree', () => {
jest.setTimeout(3 * 60 * 1000)
@@ -58,14 +58,14 @@ describe('siteTree', () => {
function validate(currentPage) {
;(currentPage.childPages || []).forEach((childPage) => {
const valid = siteTreeValidate(childPage)
const isValid = siteTreeValidate(childPage)
let errors
if (!valid) {
if (!isValid) {
errors = `file ${childPage.page.fullPath}: ${formatAjvErrors(siteTreeValidate.errors)}`
}
expect(valid, errors).toBe(true)
expect(isValid, errors).toBe(true)
// Run recurisvely until we run out of child pages
validate(childPage)

View File

@@ -10,7 +10,7 @@ import yaml from 'js-yaml'
import { getContents } from '#src/workflows/git-utils.js'
import permissionSchema from './permission-list-schema.js'
import enabledSchema from './enabled-list-schema.js'
import { validateData } from '../../rest/scripts/utils/validate-data.js'
import { validateJson } from '#src/tests/lib/validate-json-schema.js'
const ENABLED_APPS_DIR = 'src/github-apps/data'
const CONFIG_FILE = 'src/github-apps/lib/config.json'
@@ -287,12 +287,20 @@ function initAppData(storage, category, data) {
async function validateAppData(data, pageType) {
if (pageType.includes('permissions')) {
for (const value of Object.values(data)) {
validateData(value, permissionSchema)
const { isValid, errors } = validateJson(permissionSchema, value)
if (!isValid) {
console.error(JSON.stringify(errors, null, 2))
throw new Error('GitHub Apps permission schema validation failed')
}
}
} else {
for (const arrayItems of Object.values(data)) {
for (const item of arrayItems) {
validateData(item, enabledSchema)
const { isValid, errors } = validateJson(enabledSchema, item)
if (!isValid) {
console.error(JSON.stringify(errors, null, 2))
throw new Error('GitHub Apps enabled apps schema validation failed')
}
}
}
}

View File

@@ -1,6 +1,6 @@
import { jest } from '@jest/globals'
import Ajv from 'ajv'
import { getJsonValidator, validateJson } from '#src/tests/lib/validate-json-schema.js'
import readJsonFile from '#src/frame/lib/read-json-file.js'
import { schemaValidator, previewsValidator, upcomingChangesValidator } from '../lib/validator.js'
import { formatAjvErrors } from '#src/tests/helpers/schemas.js'
@@ -11,15 +11,8 @@ const allVersionValues = Object.values(allVersions)
const graphqlVersions = allVersionValues.map((v) => v.openApiVersionName)
const graphqlTypes = readJsonFile('./src/graphql/lib/types.json').map((t) => t.kind)
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true })
const previewsValidate = ajv.compile(previewsValidator)
const upcomingChangesValidate = ajv.compile(upcomingChangesValidator)
// setup ajv validator functions for each graphql type (e.g. queries, mutations,
// etc.)
const schemaValidatorFunctions = {}
graphqlTypes.forEach((type) => {
schemaValidatorFunctions[type] = ajv.compile(schemaValidator[type])
})
const previewsValidate = getJsonValidator(previewsValidator)
const upcomingChangesValidate = getJsonValidator(upcomingChangesValidator)
describe('graphql json files', () => {
jest.setTimeout(3 * 60 * 1000)
@@ -38,16 +31,16 @@ describe('graphql json files', () => {
if (typeObjsTested.has(key)) return
typeObjsTested.add(key)
const valid = schemaValidatorFunctions[type](typeObj)
let errors
const { isValid, errors } = validateJson(schemaValidator[type], typeObj)
if (!valid) {
errors = `kind: ${typeObj.kind}, name: ${typeObj.name}: ${formatAjvErrors(
schemaValidatorFunctions[type].errors,
let formattedErrors = errors
if (!isValid) {
formattedErrors = `kind: ${typeObj.kind}, name: ${typeObj.name}: ${formatAjvErrors(
errors,
)}`
}
expect(valid, errors).toBe(true)
expect(isValid, formattedErrors).toBe(true)
})
})
})
@@ -57,14 +50,14 @@ describe('graphql json files', () => {
graphqlVersions.forEach((version) => {
const previews = readJsonFile(`${GRAPHQL_DATA_DIR}/${version}/previews.json`)
previews.forEach((preview) => {
const valid = previewsValidate(preview)
const isValid = previewsValidate(preview)
let errors
if (!valid) {
if (!isValid) {
errors = formatAjvErrors(previewsValidate.errors)
}
expect(valid, errors).toBe(true)
expect(isValid, errors).toBe(true)
})
})
})
@@ -75,14 +68,14 @@ describe('graphql json files', () => {
for (const changes of Object.values(upcomingChanges)) {
// each object value is an array of changes
changes.forEach((changeObj) => {
const valid = upcomingChangesValidate(changeObj)
const isValid = upcomingChangesValidate(changeObj)
let errors
if (!valid) {
if (!isValid) {
errors = formatAjvErrors(upcomingChangesValidate.errors)
}
expect(valid, errors).toBe(true)
expect(isValid, errors).toBe(true)
})
}
})

View File

@@ -6,10 +6,10 @@ import { jest } from '@jest/globals'
import { liquid } from '#src/content-render/index.js'
import learningTracksSchema from '../lib/learning-tracks-schema.js'
import { formatAjvErrors } from '#src/tests/helpers/schemas.js'
import { ajvValidate } from '#src/tests/lib/ajv-validate.js'
import { getJsonValidator } from '#src/tests/lib/validate-json-schema.js'
const learningTrackRootPath = 'data/learning-tracks'
const jsonValidator = ajvValidate(learningTracksSchema)
const validate = getJsonValidator(learningTracksSchema)
const yamlWalkOptions = {
globs: ['**/*.yml'],
directories: false,
@@ -31,14 +31,14 @@ describe('lint learning tracks', () => {
})
it('matches the schema', () => {
const valid = jsonValidator(yamlContent)
const isValid = validate(yamlContent)
let errors
if (!valid) {
errors = formatAjvErrors(jsonValidator.errors)
if (!isValid) {
errors = formatAjvErrors(validate.errors)
}
expect(valid, errors).toBe(true)
expect(isValid, errors).toBe(true)
})
it('contains valid liquid', () => {

View File

@@ -1,10 +1,9 @@
import Ajv from 'ajv'
import { getJsonValidator } from '#src/tests/lib/validate-json-schema.js'
import { productMap } from '#src/products/lib/all-products.js'
import { formatAjvErrors } from '#src/tests/helpers/schemas.js'
import schema from '#src/tests/helpers/schemas/products-schema.js'
const ajv = new Ajv({ allErrors: true })
const validate = ajv.compile(schema)
const validate = getJsonValidator(schema)
describe('products module', () => {
test('is an object with product ids as keys', () => {
@@ -14,13 +13,13 @@ describe('products module', () => {
test('every product is valid', () => {
Object.values(productMap).forEach((product) => {
const valid = validate(product)
const isValid = validate(product)
let errors
if (!valid) {
errors = formatAjvErrors(valid.errors)
if (!isValid) {
errors = formatAjvErrors(validate.errors)
}
expect(valid, errors).toBe(true)
expect(isValid, errors).toBe(true)
})
})
})

View File

@@ -5,10 +5,10 @@ import { jest } from '@jest/globals'
import releaseNotesSchema from '../lib/release-notes-schema.js'
import { formatAjvErrors } from '#src/tests/helpers/schemas.js'
import { ajvValidate } from '#src/tests/lib/ajv-validate.js'
import { getJsonValidator } from '#src/tests/lib/validate-json-schema.js'
const ghesReleaseNoteRootPath = 'data/release-notes'
const jsonValidator = ajvValidate(releaseNotesSchema)
const validate = getJsonValidator(releaseNotesSchema)
const yamlWalkOptions = {
globs: ['**/*.yml'],
directories: false,
@@ -29,14 +29,14 @@ describe('lint enterprise release notes', () => {
})
it('matches the schema', () => {
const valid = jsonValidator(yamlContent)
const isValid = validate(yamlContent)
let errors
if (!valid) {
errors = formatAjvErrors(jsonValidator.errors)
if (!isValid) {
errors = formatAjvErrors(validate.errors)
}
expect(valid, errors).toBe(true)
expect(isValid, errors).toBe(true)
})
})
})

View File

@@ -7,7 +7,7 @@ import mergeAllOf from 'json-schema-merge-allof'
import { renderContent } from '#src/content-render/index.js'
import getCodeSamples from './create-rest-examples.js'
import operationSchema from './operation-schema.js'
import { validateData } from './validate-data.js'
import { validateJson } from '#src/tests/lib/validate-json-schema.js'
import { getBodyParams } from './get-body-params.js'
export default class Operation {
@@ -59,7 +59,11 @@ export default class Operation {
this.renderPreviewNotes(),
])
validateData(this, operationSchema)
const { isValid, errors } = validateJson(operationSchema, this)
if (!isValid) {
console.error(JSON.stringify(errors, null, 2))
throw new Error('Invalid OpenAPI operation found')
}
}
async renderDescription() {

View File

@@ -1,10 +0,0 @@
import Ajv from 'ajv'
const ajv = new Ajv()
export async function validateData(data, schema) {
const valid = ajv.validate(schema, data)
if (!valid) {
console.error(JSON.stringify(ajv.errors, null, 2))
throw new Error('Invalid OpenAPI operation found')
}
}

View File

@@ -2,7 +2,7 @@ import fs from 'fs'
import yaml from 'js-yaml'
import { jest } from '@jest/globals'
import { ajvValidate } from '#src/tests/lib/ajv-validate.js'
import { getJsonValidator } from '#src/tests/lib/validate-json-schema.js'
import { formatAjvErrors } from '#src/tests/helpers/schemas.js'
import secretScanningSchema from '../lib/secret-scanning-schema.js'
@@ -10,16 +10,16 @@ jest.useFakeTimers({ legacyFakeTimers: true })
describe('lint secret-scanning', () => {
const yamlContent = yaml.load(fs.readFileSync('data/secret-scanning.yml', 'utf8'))
const jsonValidate = ajvValidate(secretScanningSchema)
const validate = getJsonValidator(secretScanningSchema)
test('matches the schema', () => {
const valid = jsonValidate(yamlContent)
const isValid = validate(yamlContent)
let errors
if (!valid) {
errors = formatAjvErrors(jsonValidate.errors)
if (!isValid) {
errors = formatAjvErrors(validate.errors)
}
expect(valid, errors).toBe(true)
expect(isValid, errors).toBe(true)
})
})

View File

@@ -1,16 +0,0 @@
import Ajv from 'ajv'
import addErrors from 'ajv-errors'
import addFormats from 'ajv-formats'
import semver from 'semver'
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true })
addFormats(ajv)
addErrors(ajv)
// *** TODO: We can drop this override once the frontmatter schema has been updated to work with AJV. ***
ajv.addFormat('semver', {
validate: (x) => semver.validRange(x),
})
export function ajvValidate(schema) {
return ajv.compile(schema)
}

View File

@@ -0,0 +1,41 @@
import Ajv from 'ajv'
import addErrors from 'ajv-errors'
import addFormats from 'ajv-formats'
import semver from 'semver'
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true })
addFormats(ajv)
addErrors(ajv)
// Custom JSON keywords
ajv.addKeyword({
keyword: 'translatable',
})
// Custom JSON formats
ajv.addFormat('semver', {
validate: (x) => semver.validRange(x),
})
// The ajv.validate function is supposed to cache
// the compiled schema, but the documentation says
// that the best permformance is achieved by calling
// the compile function and then calling validate.
// So when the same schema is validated multiple times,
// this is the best function to use. If the schema
// changes from one call to the next, then the validateJson
// function makes more sense to use.
export function getJsonValidator(schema) {
return ajv.compile(schema)
}
// The next call to ajv.validate will overwrite
// the ajv.errors property, so returning it here
// ensures that it remains accessible.
export function validateJson(schema, data) {
const isValid = ajv.validate(schema, data)
return {
isValid,
errors: isValid ? null : structuredClone(ajv.errors),
}
}

View File

@@ -1,6 +1,6 @@
import { jest } from '@jest/globals'
import Ajv from 'ajv'
import { getJsonValidator } from '#src/tests/lib/validate-json-schema.js'
import { allVersions } from '#src/versions/lib/all-versions.js'
import { latest } from '#src/versions/lib/enterprise-server-releases.js'
import schema from '#src/tests/helpers/schemas/versions-schema.js'
@@ -9,8 +9,7 @@ import { formatAjvErrors } from '#src/tests/helpers/schemas.js'
jest.useFakeTimers({ legacyFakeTimers: true })
const ajv = new Ajv({ allErrors: true })
const validate = ajv.compile(schema)
const validate = getJsonValidator(schema)
describe('versions module', () => {
test('is an object with versions as keys', () => {
@@ -20,14 +19,14 @@ describe('versions module', () => {
test('every version is valid', () => {
Object.values(allVersions).forEach((versionObj) => {
const valid = validate(versionObj)
const isValid = validate(versionObj)
let errors
if (!valid) {
if (!isValid) {
errors = `version '${versionObj.version}': ${formatAjvErrors(validate.errors)}`
}
expect(valid, errors).toBe(true)
expect(isValid, errors).toBe(true)
})
})

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node
import Ajv from 'ajv'
import { get, isPlainObject } from 'lodash-es'
import { getJsonValidator } from '#src/tests/lib/validate-json-schema.js'
import { renderContent } from '#src/content-render/index.js'
import webhookSchema from './webhook-schema.js'
import { getBodyParams } from '../../rest/scripts/utils/get-body-params.js'
@@ -15,6 +15,8 @@ const NO_CHILD_PROPERTIES = [
'sender',
]
const validate = getJsonValidator(webhookSchema)
export default class Webhook {
#webhook
constructor(webhook) {
@@ -50,10 +52,9 @@ export default class Webhook {
async process() {
await Promise.all([this.renderDescription(), this.renderBodyParameterDescriptions()])
const ajv = new Ajv()
const valid = ajv.validate(webhookSchema, this)
if (!valid) {
console.error(JSON.stringify(ajv.errors, null, 2))
const isValid = validate(this)
if (!isValid) {
console.error(JSON.stringify(validate.errors, null, 2))
throw new Error(`Invalid OpenAPI webhook found: ${this.category}`)
}
}