feat(samples): add support for contentSchema keyword (#8907)

This change is specific to JSON Schema 2020-12
and OpenAPI 3.1.0.

Refs #8577
This commit is contained in:
Vladimír Gorej
2023-06-09 21:35:44 +02:00
committed by GitHub
parent d72b72c5c6
commit 6c622a87e7
9 changed files with 174 additions and 35 deletions

View File

@@ -0,0 +1,6 @@
/**
* @prettier
*/
export const SCALAR_TYPES = ["integer", "number", "string", "boolean", "null"]
export const ALL_TYPES = ["array", "object", ...SCALAR_TYPES]

View File

@@ -0,0 +1,24 @@
/**
* @prettier
*/
import { ALL_TYPES } from "./constants"
const foldType = (type) => {
if (Array.isArray(type)) {
if (type.includes("array")) {
return "array"
} else if (type.includes("object")) {
return "object"
} else if (ALL_TYPES.includes(type.at(0))) {
return type.at(0)
}
}
if (typeof type === "string" && ALL_TYPES.includes(type)) {
return type
}
return null
}
export default foldType

View File

@@ -0,0 +1,24 @@
/**
* @prettier
*/
import isPlainObject from "lodash/isPlainObject"
export const isURI = (uri) => {
try {
return new URL(uri) && true
} catch {
return false
}
}
export const isBooleanJSONSchema = (schema) => {
return typeof schema === "boolean"
}
export const isJSONSchemaObject = (schema) => {
return isPlainObject(schema)
}
export const isJSONSchema = (schema) => {
return isBooleanJSONSchema(schema) || isJSONSchemaObject(schema)
}

View File

@@ -1,10 +1,23 @@
/** /**
* @prettier * @prettier
*/ */
export const isURI = (uri) => { import { isBooleanJSONSchema, isJSONSchemaObject } from "./predicates"
try {
return new URL(uri) && true export const fromJSONBooleanSchema = (schema) => {
} catch { if (schema === false) {
return false return { not: {} }
} }
return {}
}
export const typeCast = (schema) => {
if (isBooleanJSONSchema(schema)) {
return fromJSONBooleanSchema(schema)
}
if (!isJSONSchemaObject(schema)) {
return {}
}
return schema
} }

View File

@@ -4,22 +4,12 @@
import XML from "xml" import XML from "xml"
import isEmpty from "lodash/isEmpty" import isEmpty from "lodash/isEmpty"
import { objectify, isFunc, normalizeArray, deeplyStripKey } from "core/utils" import { objectify, normalizeArray, deeplyStripKey } from "core/utils"
import memoizeN from "../../../../../helpers/memoizeN" import memoizeN from "../../../../../helpers/memoizeN"
import typeMap from "./types/index" import typeMap from "./types/index"
import { isURI } from "./core/utils" import { isURI } from "./core/predicates"
import foldType from "./core/fold-type"
const primitive = (schema) => { import { typeCast } from "./core/utils"
schema = objectify(schema)
const { type: typeList } = schema
const type = Array.isArray(typeList) ? typeList.at(0) : typeList
if (Object.hasOwn(typeMap, type)) {
return typeMap[type](schema)
}
return `Unknown Type: ${type}`
}
/** /**
* Do a couple of quick sanity tests to ensure the value * Do a couple of quick sanity tests to ensure the value
@@ -140,7 +130,9 @@ export const sampleFromSchemaGeneric = (
exampleOverride = undefined, exampleOverride = undefined,
respectXML = false respectXML = false
) => { ) => {
if (schema && isFunc(schema.toJS)) schema = schema.toJS() if (typeof schema?.toJS === "function") schema = schema.toJS()
schema = typeCast(schema)
let usePlainValue = let usePlainValue =
exampleOverride !== undefined || exampleOverride !== undefined ||
(schema && schema.example !== undefined) || (schema && schema.example !== undefined) ||
@@ -151,7 +143,7 @@ export const sampleFromSchemaGeneric = (
const hasAnyOf = const hasAnyOf =
!usePlainValue && schema && schema.anyOf && schema.anyOf.length > 0 !usePlainValue && schema && schema.anyOf && schema.anyOf.length > 0
if (!usePlainValue && (hasOneOf || hasAnyOf)) { if (!usePlainValue && (hasOneOf || hasAnyOf)) {
const schemaToAdd = objectify(hasOneOf ? schema.oneOf[0] : schema.anyOf[0]) const schemaToAdd = typeCast(hasOneOf ? schema.oneOf[0] : schema.anyOf[0])
liftSampleHelper(schemaToAdd, schema, config) liftSampleHelper(schemaToAdd, schema, config)
if (!schema.xml && schemaToAdd.xml) { if (!schema.xml && schemaToAdd.xml) {
schema.xml = schemaToAdd.xml schema.xml = schemaToAdd.xml
@@ -211,6 +203,7 @@ export const sampleFromSchemaGeneric = (
items, items,
contains, contains,
} = schema || {} } = schema || {}
type = foldType(type)
let { includeReadOnly, includeWriteOnly } = config let { includeReadOnly, includeWriteOnly } = config
xml = xml || {} xml = xml || {}
let { name, prefix, namespace } = xml let { name, prefix, namespace } = xml
@@ -339,9 +332,9 @@ export const sampleFromSchemaGeneric = (
} else if (enumAttrVal !== undefined) { } else if (enumAttrVal !== undefined) {
_attr[props[propName].xml.name || propName] = enumAttrVal _attr[props[propName].xml.name || propName] = enumAttrVal
} else { } else {
_attr[props[propName].xml.name || propName] = primitive( const propSchema = typeCast(props[propName])
props[propName] const attrName = props[propName].xml.name || propName
) _attr[attrName] = typeMap[propSchema.type](propSchema)
} }
return return
@@ -468,7 +461,7 @@ export const sampleFromSchemaGeneric = (
] ]
} }
itemSamples = typeMap.array(schema, itemSamples) itemSamples = typeMap.array(schema, { sample: itemSamples })
if (xml.wrapped) { if (xml.wrapped) {
res[displayName] = itemSamples res[displayName] = itemSamples
if (!isEmpty(_attr)) { if (!isEmpty(_attr)) {
@@ -606,7 +599,7 @@ export const sampleFromSchemaGeneric = (
} }
} }
sampleArray = typeMap.array(schema, sampleArray) sampleArray = typeMap.array(schema, { sample: sampleArray })
if (respectXML && xml.wrapped) { if (respectXML && xml.wrapped) {
res[displayName] = sampleArray res[displayName] = sampleArray
if (!isEmpty(_attr)) { if (!isEmpty(_attr)) {
@@ -650,7 +643,7 @@ export const sampleFromSchemaGeneric = (
} }
propertyAddedCounter++ propertyAddedCounter++
} else if (additionalProperties) { } else if (additionalProperties) {
const additionalProps = objectify(additionalProperties) const additionalProps = typeCast(additionalProperties)
const additionalPropSample = sampleFromSchemaGeneric( const additionalPropSample = sampleFromSchemaGeneric(
additionalProps, additionalProps,
config, config,
@@ -699,7 +692,15 @@ export const sampleFromSchemaGeneric = (
value = normalizeArray(schema.enum)[0] value = normalizeArray(schema.enum)[0]
} else if (schema) { } else if (schema) {
// display schema default // display schema default
value = primitive(schema) const contentSample = Object.hasOwn(schema, "contentSchema")
? sampleFromSchemaGeneric(
typeCast(schema.contentSchema),
config,
undefined,
respectXML
)
: undefined
value = typeMap[type](schema, { sample: contentSample })
} else { } else {
return return
} }

View File

@@ -45,8 +45,8 @@ export const applyArrayConstraints = (array, constraints = {}) => {
return constrainedArray return constrainedArray
} }
const arrayType = (schema, sampleArray) => { const arrayType = (schema, { sample }) => {
return applyArrayConstraints(sampleArray, schema) return applyArrayConstraints(sample, schema)
} }
export default arrayType export default arrayType

View File

@@ -19,4 +19,12 @@ const typeMap = {
null: nullType, null: nullType,
} }
export default typeMap export default new Proxy(typeMap, {
get(target, prop) {
if (Object.hasOwn(target, prop)) {
return target[prop]
}
return () => `Unknown Type: ${prop}`
},
})

View File

@@ -4,6 +4,7 @@
import identity from "lodash/identity" import identity from "lodash/identity"
import { string as randomString, randexp } from "../core/random" import { string as randomString, randexp } from "../core/random"
import { isJSONSchema } from "../core/predicates"
import emailGenerator from "../generators/email" import emailGenerator from "../generators/email"
import idnEmailGenerator from "../generators/idn-email" import idnEmailGenerator from "../generators/idn-email"
import hostnameGenerator from "../generators/hostname" import hostnameGenerator from "../generators/hostname"
@@ -118,8 +119,9 @@ const applyStringConstraints = (string, constraints = {}) => {
return constrainedString return constrainedString
} }
const stringType = (schema) => { const stringType = (schema, { sample } = {}) => {
const { pattern, format, contentEncoding, contentMediaType } = schema const { contentEncoding, contentMediaType, contentSchema } = schema
const { pattern, format } = schema
const encode = encoderAPI(contentEncoding) || identity const encode = encoderAPI(contentEncoding) || identity
let generatedString let generatedString
@@ -127,7 +129,17 @@ const stringType = (schema) => {
generatedString = randexp(pattern) generatedString = randexp(pattern)
} else if (typeof format === "string") { } else if (typeof format === "string") {
generatedString = generateFormat(schema) generatedString = generateFormat(schema)
} else if (typeof contentMediaType === "string") { } else if (
isJSONSchema(contentSchema) &&
typeof contentMediaType !== "undefined" &&
typeof sample !== "undefined"
) {
if (Array.isArray(sample) || typeof sample === "object") {
generatedString = JSON.stringify(sample)
} else {
generatedString = String(sample)
}
} else if (typeof contentMediaType !== "undefined") {
const mediaTypeGenerator = mediaTypeAPI(contentMediaType) const mediaTypeGenerator = mediaTypeAPI(contentMediaType)
if (typeof mediaTypeGenerator === "function") { if (typeof mediaTypeGenerator === "function") {
generatedString = mediaTypeGenerator(schema) generatedString = mediaTypeGenerator(schema)

View File

@@ -2,7 +2,6 @@
* @prettier * @prettier
* *
*/ */
import { Buffer } from "node:buffer"
import { fromJS } from "immutable" import { fromJS } from "immutable"
import { import {
createXMLExample, createXMLExample,
@@ -291,6 +290,58 @@ describe("sampleFromSchema", () => {
expect(sampleFromSchema(definition)).toMatch(base64Regex) expect(sampleFromSchema(definition)).toMatch(base64Regex)
}) })
it("should handle contentSchema defined as type=object", function () {
const definition = fromJS({
type: "string",
contentMediaType: "application/json",
contentSchema: {
type: "object",
properties: {
a: { const: "b" },
},
},
})
expect(sampleFromSchema(definition)).toStrictEqual('{"a":"b"}')
})
it("should handle contentSchema defined as type=string", function () {
const definition = fromJS({
type: "string",
contentMediaType: "text/plain",
contentSchema: {
type: "string",
},
})
expect(sampleFromSchema(definition)).toStrictEqual("string")
})
it("should handle contentSchema defined as type=number", function () {
const definition = fromJS({
type: "string",
contentMediaType: "text/plain",
contentSchema: {
type: "number",
},
})
expect(sampleFromSchema(definition)).toStrictEqual("0")
})
it("should handle contentSchema defined as type=number + contentEncoding", function () {
const definition = fromJS({
type: "string",
contentEncoding: "base16",
contentMediaType: "text/plain",
contentSchema: {
type: "number",
},
})
expect(sampleFromSchema(definition)).toStrictEqual("30")
})
it("should handle type keyword defined as list of types", function () { it("should handle type keyword defined as list of types", function () {
const definition = fromJS({ type: ["object", "string"] }) const definition = fromJS({ type: ["object", "string"] })
const expected = {} const expected = {}