mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 21:01:37 +03:00
Switch to AJV for validating JSON Schema (#9191)
As ~~requested~~ suggested by @radeusgd # Important Notes None
This commit is contained in:
parent
97033a2ff4
commit
0a28d91d35
4
app/ide-desktop/.vscode/react.code-snippets
vendored
4
app/ide-desktop/.vscode/react.code-snippets
vendored
@ -24,5 +24,9 @@
|
||||
"useState": {
|
||||
"prefix": ["$s", "usestate"],
|
||||
"body": ["const [$1, set${1/(.*)/${1:/pascalcase}/}] = React.useState($2)"]
|
||||
},
|
||||
"section": {
|
||||
"prefix": ["$S", "section"],
|
||||
"body": ["// ====${1/./=/g}====", "// === $1 ===", "// ====${1/./=/g}===="]
|
||||
}
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ const DEFAULT_IMPORT_ONLY_MODULES =
|
||||
const OUR_MODULES = 'enso-.*'
|
||||
const RELATIVE_MODULES =
|
||||
'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|detect|file-associations|index|ipc|log|naming|paths|preload|project-management|security|url-associations|#\\u002F.*'
|
||||
const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss|${RELATIVE_MODULES}`
|
||||
const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss|ajv\\u002Fdist\\u002F2020|${RELATIVE_MODULES}`
|
||||
const STRING_LITERAL = ':matches(Literal[raw=/^["\']/], TemplateLiteral)'
|
||||
const JSX = ':matches(JSXElement, JSXFragment)'
|
||||
const NOT_PASCAL_CASE = '/^(?!do[A-Z])(?!_?([A-Z][a-z0-9]*)+$)/'
|
||||
|
@ -34,6 +34,7 @@
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@sentry/react": "^7.74.0",
|
||||
"ajv": "^8.12.0",
|
||||
"enso-common": "^1.0.0",
|
||||
"is-network-error": "^1.0.1",
|
||||
"react": "^18.2.0",
|
||||
|
352
app/ide-desktop/lib/dashboard/src/components/JSONSchemaInput.tsx
Normal file
352
app/ide-desktop/lib/dashboard/src/components/JSONSchemaInput.tsx
Normal file
@ -0,0 +1,352 @@
|
||||
/** @file A dynamic wizard for creating an arbitrary type of Data Link. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
|
||||
import Autocomplete from '#/components/Autocomplete'
|
||||
import Dropdown from '#/components/Dropdown'
|
||||
|
||||
import * as jsonSchema from '#/utilities/jsonSchema'
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
// =======================
|
||||
// === JSONSchemaInput ===
|
||||
// =======================
|
||||
|
||||
/** Props for a {@link JSONSchemaInput}. */
|
||||
export interface JSONSchemaInputProps {
|
||||
readonly dropdownTitle?: string
|
||||
readonly defs: Record<string, object>
|
||||
readonly readOnly?: boolean
|
||||
readonly schema: object
|
||||
readonly path: string
|
||||
readonly getValidator: (path: string) => (value: unknown) => boolean
|
||||
readonly value: NonNullable<unknown> | null
|
||||
readonly setValue: React.Dispatch<React.SetStateAction<NonNullable<unknown> | null>>
|
||||
}
|
||||
|
||||
/** A dynamic wizard for creating an arbitrary type of Data Link. */
|
||||
export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
const { dropdownTitle, readOnly = false, defs, schema, path, getValidator } = props
|
||||
const { value: valueRaw, setValue: setValueRaw } = props
|
||||
// The functionality for inputting `enso-secret`s SHOULD be injected using a plugin,
|
||||
// but it is more convenient to avoid having plugin infrastructure.
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const [value, setValue] = React.useState(valueRaw)
|
||||
const [autocompleteText, setAutocompleteText] = React.useState(() =>
|
||||
typeof value === 'string' ? value : null
|
||||
)
|
||||
const [selectedChildIndex, setSelectedChildIndex] = React.useState<number | null>(null)
|
||||
const [autocompleteItems, setAutocompleteItems] = React.useState<string[] | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
setValue(valueRaw)
|
||||
// `initializing` is not a dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [valueRaw])
|
||||
|
||||
React.useEffect(() => {
|
||||
setValueRaw(value)
|
||||
// `setStateRaw` is a callback, not a dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value])
|
||||
|
||||
// NOTE: `enum` schemas omitted for now as they are not yet used.
|
||||
if ('const' in schema) {
|
||||
// This value cannot change.
|
||||
return null
|
||||
} else {
|
||||
const children: JSX.Element[] = []
|
||||
if ('type' in schema) {
|
||||
switch (schema.type) {
|
||||
case 'string': {
|
||||
if ('format' in schema && schema.format === 'enso-secret') {
|
||||
const isValid = typeof value === 'string' && value !== ''
|
||||
if (autocompleteItems == null) {
|
||||
setAutocompleteItems([])
|
||||
void (async () => {
|
||||
const secrets = await backend.listSecrets()
|
||||
// FIXME: Extract secret path instead of ID.
|
||||
setAutocompleteItems(secrets.map(secret => secret.id))
|
||||
})()
|
||||
}
|
||||
children.push(
|
||||
<div
|
||||
className={`rounded-2xl border ${
|
||||
isValid ? 'border-black/10' : 'border-red-700/60'
|
||||
}`}
|
||||
>
|
||||
<Autocomplete
|
||||
items={autocompleteItems ?? []}
|
||||
itemToKey={item => item}
|
||||
itemToString={item => item}
|
||||
placeholder="Enter secret path"
|
||||
matches={(item, text) => item.toLowerCase().includes(text.toLowerCase())}
|
||||
values={isValid ? [value] : []}
|
||||
setValues={values => {
|
||||
setValue(values[0])
|
||||
}}
|
||||
text={autocompleteText}
|
||||
setText={setAutocompleteText}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
children.push(
|
||||
<input
|
||||
type="text"
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
size={1}
|
||||
className={`rounded-full w-40 px-2 bg-transparent border leading-170 h-6 py-px disabled:opacity-50 read-only:opacity-75 read-only:cursor-not-allowed ${
|
||||
getValidator(path)(value) ? 'border-black/10' : 'border-red-700/60'
|
||||
}`}
|
||||
placeholder="Enter text"
|
||||
onChange={event => {
|
||||
const newValue: string = event.currentTarget.value
|
||||
setValue(newValue)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'number': {
|
||||
children.push(
|
||||
<input
|
||||
type="number"
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
className={`rounded-full w-40 px-2 bg-transparent border leading-170 h-6 py-px disabled:opacity-50 read-only:opacity-75 read-only:cursor-not-allowed ${
|
||||
getValidator(path)(value) ? 'border-black/10' : 'border-red-700/60'
|
||||
}`}
|
||||
placeholder="Enter number"
|
||||
onChange={event => {
|
||||
const newValue: number = event.currentTarget.valueAsNumber
|
||||
if (Number.isFinite(newValue)) {
|
||||
setValue(newValue)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'integer': {
|
||||
children.push(
|
||||
<input
|
||||
type="number"
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
className={`rounded-full w-40 px-2 bg-transparent border leading-170 h-6 py-px disabled:opacity-50 read-only:opacity-75 read-only:cursor-not-allowed ${
|
||||
getValidator(path)(value) ? 'border-black/10' : 'border-red-700/60'
|
||||
}`}
|
||||
placeholder="Enter integer"
|
||||
onChange={event => {
|
||||
const newValue: number = Math.floor(event.currentTarget.valueAsNumber)
|
||||
if (Number.isFinite(newValue)) {
|
||||
setValue(newValue)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'boolean': {
|
||||
children.push(
|
||||
<input
|
||||
type="checkbox"
|
||||
readOnly={readOnly}
|
||||
checked={typeof value === 'boolean' && value}
|
||||
onChange={event => {
|
||||
setValue(event.currentTarget.checked)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'object': {
|
||||
const propertiesObject =
|
||||
'properties' in schema ? object.asObject(schema.properties) ?? {} : {}
|
||||
const requiredProperties =
|
||||
'required' in schema && Array.isArray(schema.required) ? schema.required : []
|
||||
const propertyDefinitions = Object.entries(propertiesObject).flatMap(
|
||||
(kv: [string, unknown]) => {
|
||||
const [k, v] = kv
|
||||
return object
|
||||
.singletonObjectOrNull(v)
|
||||
.map(childSchema => ({ key: k, schema: childSchema }))
|
||||
}
|
||||
)
|
||||
if (jsonSchema.constantValue(defs, schema).length !== 1) {
|
||||
children.push(
|
||||
<div className="flex flex-col gap-1 rounded-2xl border border-black/10 p-2">
|
||||
{propertyDefinitions.map(definition => {
|
||||
const { key, schema: childSchema } = definition
|
||||
const isOptional = !requiredProperties.includes(key)
|
||||
return jsonSchema.constantValue(defs, childSchema).length === 1 ? null : (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-wrap items-center"
|
||||
{...('description' in childSchema
|
||||
? { title: String(childSchema.description) }
|
||||
: {})}
|
||||
>
|
||||
<div
|
||||
className={`inline-block h-6 leading-170 py-px w-28 whitespace-nowrap ${
|
||||
isOptional ? 'cursor-pointer' : ''
|
||||
} ${value != null && key in value ? '' : 'opacity-50'}`}
|
||||
onClick={() => {
|
||||
if (isOptional) {
|
||||
setValue(oldValue => {
|
||||
if (oldValue != null && key in oldValue) {
|
||||
// This is SAFE, as `value` is an untyped object.
|
||||
// The removed key is intentionally unused.
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
|
||||
const { [key]: removed, ...newValue } = oldValue as Record<
|
||||
string,
|
||||
NonNullable<unknown> | null
|
||||
>
|
||||
return newValue
|
||||
} else {
|
||||
return {
|
||||
...oldValue,
|
||||
[key]: jsonSchema.constantValue(defs, childSchema, true)[0],
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{'title' in childSchema ? String(childSchema.title) : key}
|
||||
</div>
|
||||
{value != null && key in value && (
|
||||
<JSONSchemaInput
|
||||
readOnly={readOnly}
|
||||
defs={defs}
|
||||
schema={childSchema}
|
||||
path={`${path}/properties/${key}`}
|
||||
getValidator={getValidator}
|
||||
// This is SAFE, as `value` is an untyped object.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
value={(value as Record<string, unknown>)[key] ?? null}
|
||||
setValue={newValue => {
|
||||
setValue(oldValue =>
|
||||
typeof oldValue === 'object' &&
|
||||
oldValue != null &&
|
||||
// This is SAFE; but there is no way to tell TypeScript that an object
|
||||
// has an index signature.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(oldValue as Record<string, unknown>)[key] === newValue
|
||||
? oldValue
|
||||
: { ...oldValue, [key]: newValue }
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ('$ref' in schema && typeof schema.$ref === 'string') {
|
||||
const referencedSchema = jsonSchema.lookupDef(defs, schema)
|
||||
if (referencedSchema != null) {
|
||||
children.push(
|
||||
<JSONSchemaInput
|
||||
{...props}
|
||||
key={schema.$ref}
|
||||
schema={referencedSchema}
|
||||
path={schema.$ref}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
if ('anyOf' in schema && Array.isArray(schema.anyOf)) {
|
||||
const childSchemas = schema.anyOf.flatMap(object.singletonObjectOrNull)
|
||||
const selectedChildSchema =
|
||||
selectedChildIndex == null ? null : childSchemas[selectedChildIndex]
|
||||
const selectedChildPath = `${path}/anyOf/${selectedChildIndex}`
|
||||
const childValue =
|
||||
selectedChildSchema == null ? [] : jsonSchema.constantValue(defs, selectedChildSchema)
|
||||
if (
|
||||
value != null &&
|
||||
(selectedChildSchema == null || getValidator(selectedChildPath)(value) !== true)
|
||||
) {
|
||||
const newIndexRaw = childSchemas.findIndex((_, index) =>
|
||||
getValidator(`${path}/anyOf/${index}`)(value)
|
||||
)
|
||||
const newIndex = selectedChildSchema == null && newIndexRaw === -1 ? 0 : newIndexRaw
|
||||
if (newIndex !== -1 && newIndex !== selectedChildIndex) {
|
||||
setSelectedChildIndex(newIndex)
|
||||
}
|
||||
}
|
||||
const dropdown = (
|
||||
<Dropdown
|
||||
readOnly={readOnly}
|
||||
items={childSchemas}
|
||||
selectedIndex={selectedChildIndex}
|
||||
render={childProps => jsonSchema.getSchemaName(defs, childProps.item)}
|
||||
className="self-start"
|
||||
onClick={(childSchema, index) => {
|
||||
setSelectedChildIndex(index)
|
||||
const newConstantValue = jsonSchema.constantValue(defs, childSchema, true)
|
||||
setValue(newConstantValue[0] ?? null)
|
||||
setSelectedChildIndex(index)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
children.push(
|
||||
<div className={`flex flex-col gap-1 ${childValue.length === 0 ? 'w-full' : ''}`}>
|
||||
{dropdownTitle != null ? (
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-6 py-1">{dropdownTitle}</div>
|
||||
{dropdown}
|
||||
</div>
|
||||
) : (
|
||||
dropdown
|
||||
)}
|
||||
{selectedChildSchema != null && (
|
||||
<JSONSchemaInput
|
||||
key={selectedChildIndex}
|
||||
defs={defs}
|
||||
readOnly={readOnly}
|
||||
schema={selectedChildSchema}
|
||||
path={selectedChildPath}
|
||||
getValidator={getValidator}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if ('allOf' in schema && Array.isArray(schema.allOf)) {
|
||||
const childSchemas = schema.allOf.flatMap(object.singletonObjectOrNull)
|
||||
const newChildren = childSchemas.map((childSchema, i) => (
|
||||
<JSONSchemaInput
|
||||
key={i}
|
||||
defs={defs}
|
||||
readOnly={readOnly}
|
||||
schema={childSchema}
|
||||
path={`${path}/allOf/${i}`}
|
||||
getValidator={getValidator}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
/>
|
||||
))
|
||||
children.push(...newChildren)
|
||||
}
|
||||
return children.length === 0 ? null : children.length === 1 && children[0] != null ? (
|
||||
children[0]
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">{...children}</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
/** @file A dynamic wizard for creating an arbitrary type of Data Link. */
|
||||
import * as React from 'react'
|
||||
|
||||
import Ajv from 'ajv/dist/2020'
|
||||
|
||||
import SCHEMA from '#/data/dataLinkSchema.json' assert { type: 'json' }
|
||||
|
||||
import type * as jsonSchemaInput from '#/components/JSONSchemaInput'
|
||||
import JSONSchemaInput from '#/components/JSONSchemaInput'
|
||||
|
||||
import * as error from '#/utilities/error'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const DEFS: Record<string, object> = SCHEMA.$defs
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const AJV = new Ajv({ formats: { 'enso-secret': true } })
|
||||
AJV.addSchema(SCHEMA)
|
||||
|
||||
// ====================
|
||||
// === getValidator ===
|
||||
// ====================
|
||||
|
||||
/** Get a known schema using a path.
|
||||
* @throws {Error} when there is no schema present at the given path. */
|
||||
function getValidator(path: string) {
|
||||
return error.assert<(value: unknown) => boolean>(() => AJV.getSchema(path))
|
||||
}
|
||||
|
||||
// =====================
|
||||
// === DataLinkInput ===
|
||||
// =====================
|
||||
|
||||
/** Props for a {@link DataLinkInput}. */
|
||||
export interface DataLinkInputProps
|
||||
extends Omit<jsonSchemaInput.JSONSchemaInputProps, 'defs' | 'getValidator' | 'path' | 'schema'> {}
|
||||
|
||||
/** A dynamic wizard for creating an arbitrary type of Data Link. */
|
||||
export default function DataLinkInput(props: DataLinkInputProps) {
|
||||
return (
|
||||
<JSONSchemaInput
|
||||
defs={DEFS}
|
||||
schema={SCHEMA.$defs.DataLink}
|
||||
path={'#/$defs/DataLink'}
|
||||
getValidator={getValidator}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
@ -3,8 +3,6 @@ import * as React from 'react'
|
||||
|
||||
import PenIcon from 'enso-assets/pen.svg'
|
||||
|
||||
import SCHEMA from '#/data/dataLinkSchema.json' assert { type: 'json' }
|
||||
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
@ -13,24 +11,18 @@ import * as backendProvider from '#/providers/BackendProvider'
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
|
||||
import type Category from '#/layouts/CategorySwitcher/Category'
|
||||
import DataLinkInput from '#/layouts/DataLinkInput'
|
||||
|
||||
import Button from '#/components/Button'
|
||||
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
|
||||
import DataLinkInput from '#/components/dashboard/DataLinkInput'
|
||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import type AssetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import * as jsonSchema from '#/utilities/jsonSchema'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const DEFS: Record<string, object> = SCHEMA.$defs
|
||||
import * as validateDataLink from '#/utilities/validateDataLink'
|
||||
|
||||
// =======================
|
||||
// === AssetProperties ===
|
||||
@ -58,7 +50,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
)
|
||||
const [isDataLinkFetched, setIsDataLinkFetched] = React.useState(false)
|
||||
const isDataLinkSubmittable = React.useMemo(
|
||||
() => jsonSchema.isMatch(DEFS, SCHEMA.$defs.DataLink, dataLinkValue),
|
||||
() => validateDataLink.validateDataLink(dataLinkValue),
|
||||
[dataLinkValue]
|
||||
)
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
|
@ -4,6 +4,8 @@ import * as React from 'react'
|
||||
import FindIcon from 'enso-assets/find.svg'
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import Label from '#/components/dashboard/Label'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
@ -52,6 +54,7 @@ export interface AssetSearchBarProps {
|
||||
/** A search bar containing a text input, and a list of suggestions. */
|
||||
export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
const { isCloud, query, setQuery, labels, suggestions: rawSuggestions } = props
|
||||
const { modalRef } = modalProvider.useModalRef()
|
||||
/** A cached query as of the start of tabbing. */
|
||||
const baseQuery = React.useRef(query)
|
||||
const [suggestions, setSuggestions] = React.useState(rawSuggestions)
|
||||
@ -172,7 +175,8 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
(!(event.target instanceof Node) || rootRef.current?.contains(event.target) !== true) &&
|
||||
eventModule.isTextInputEvent(event) &&
|
||||
event.key !== ' ' &&
|
||||
(!detect.isOnMacOS() || event.key !== 'Delete')
|
||||
(!detect.isOnMacOS() || event.key !== 'Delete') &&
|
||||
modalRef.current == null
|
||||
) {
|
||||
searchRef.current?.focus()
|
||||
}
|
||||
@ -193,7 +197,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('keyup', onKeyUp)
|
||||
}
|
||||
}, [setQuery])
|
||||
}, [setQuery, /* should never change */ modalRef])
|
||||
|
||||
// Reset `querySource` after all other effects have run.
|
||||
React.useEffect(() => {
|
||||
|
@ -1,390 +0,0 @@
|
||||
/** @file A dynamic wizard for creating an arbitrary type of Data Link. */
|
||||
import * as React from 'react'
|
||||
|
||||
import SCHEMA from '#/data/dataLinkSchema.json' assert { type: 'json' }
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
|
||||
import Autocomplete from '#/components/Autocomplete'
|
||||
import Dropdown from '#/components/Dropdown'
|
||||
|
||||
import * as jsonSchema from '#/utilities/jsonSchema'
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const DEFS: Record<string, object> = SCHEMA.$defs
|
||||
|
||||
// =====================
|
||||
// === constantValue ===
|
||||
// =====================
|
||||
|
||||
/** The value of the schema, if it can only have one possible value. */
|
||||
function constantValue(schema: object, partial = false) {
|
||||
return jsonSchema.constantValue(DEFS, schema, partial)
|
||||
}
|
||||
|
||||
// =====================
|
||||
// === getSchemaName ===
|
||||
// =====================
|
||||
|
||||
const SCHEMA_NAMES = new WeakMap<object, string>()
|
||||
|
||||
/** Return a human-readable name representing a schema. */
|
||||
function getSchemaNameHelper(schema: object): string {
|
||||
if ('title' in schema) {
|
||||
return String(schema.title)
|
||||
} else if ('type' in schema) {
|
||||
return String(schema.type)
|
||||
} else if ('$ref' in schema) {
|
||||
const referencedSchema = jsonSchema.lookupDef(DEFS, schema)
|
||||
return referencedSchema == null ? '(unknown)' : getSchemaName(referencedSchema)
|
||||
} else if ('anyOf' in schema) {
|
||||
const members = Array.isArray(schema.anyOf) ? schema.anyOf : []
|
||||
return (
|
||||
members.flatMap(object.singletonObjectOrNull).map(getSchemaName).join(' | ') || '(unknown)'
|
||||
)
|
||||
} else if ('allOf' in schema) {
|
||||
const members = Array.isArray(schema.allOf) ? schema.allOf : []
|
||||
return members.flatMap(object.singletonObjectOrNull).join(' & ') || '(unknown)'
|
||||
} else {
|
||||
return '(unknown)'
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a human-readable name representing a schema.
|
||||
* This function is a memoized version of {@link getSchemaNameHelper}. */
|
||||
function getSchemaName(schema: object) {
|
||||
const cached = SCHEMA_NAMES.get(schema)
|
||||
if (cached != null) {
|
||||
return cached
|
||||
} else {
|
||||
const name = getSchemaNameHelper(schema)
|
||||
SCHEMA_NAMES.set(schema, name)
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// === DataLinkInput ===
|
||||
// =====================
|
||||
|
||||
/** Props for a {@link DataLinkInput}. */
|
||||
export interface DataLinkInputProps {
|
||||
readonly dropdownTitle?: string
|
||||
readonly schema?: object
|
||||
readonly readOnly?: boolean
|
||||
readonly value: NonNullable<unknown> | null
|
||||
readonly setValue: React.Dispatch<React.SetStateAction<NonNullable<unknown> | null>>
|
||||
}
|
||||
|
||||
/** A dynamic wizard for creating an arbitrary type of Data Link. */
|
||||
export default function DataLinkInput(props: DataLinkInputProps) {
|
||||
const { dropdownTitle, schema = SCHEMA.$defs.DataLink, readOnly = false, value: valueRaw } = props
|
||||
const { setValue: setValueRaw } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const [value, setValue] = React.useState(valueRaw)
|
||||
const [autocompleteText, setAutocompleteText] = React.useState(() =>
|
||||
typeof value === 'string' ? value : null
|
||||
)
|
||||
const [selectedChildIndex, setSelectedChildIndex] = React.useState<number | null>(null)
|
||||
const [autocompleteItems, setAutocompleteItems] = React.useState<string[] | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
setValue(valueRaw)
|
||||
// `initializing` is not a dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [valueRaw])
|
||||
|
||||
React.useEffect(() => {
|
||||
setValueRaw(value)
|
||||
// `setStateRaw` is a callback, not a dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value])
|
||||
|
||||
// NOTE: `enum` schemas omitted for now as they are not yet used.
|
||||
if ('const' in schema) {
|
||||
// This value cannot change.
|
||||
return null
|
||||
} else if ('type' in schema) {
|
||||
switch (schema.type) {
|
||||
case 'string': {
|
||||
if ('format' in schema && schema.format === 'enso-secret') {
|
||||
const isValid = typeof value === 'string' && value !== ''
|
||||
if (autocompleteItems == null) {
|
||||
setAutocompleteItems([])
|
||||
void (async () => {
|
||||
const secrets = await backend.listSecrets()
|
||||
// FIXME: Extract secret path instead of ID.
|
||||
setAutocompleteItems(secrets.map(secret => secret.id))
|
||||
})()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl border ${isValid ? 'border-black/10' : 'border-red-700/60'}`}
|
||||
>
|
||||
<Autocomplete
|
||||
items={autocompleteItems ?? []}
|
||||
itemToKey={item => item}
|
||||
itemToString={item => item}
|
||||
placeholder="Enter secret path"
|
||||
matches={(item, text) => item.toLowerCase().includes(text.toLowerCase())}
|
||||
values={isValid ? [value] : []}
|
||||
setValues={values => {
|
||||
setValue(values[0])
|
||||
}}
|
||||
text={autocompleteText}
|
||||
setText={setAutocompleteText}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
size={1}
|
||||
className={`rounded-full w-40 px-2 bg-transparent border leading-170 h-6 py-px disabled:opacity-50 read-only:opacity-75 read-only:cursor-not-allowed ${
|
||||
jsonSchema.isMatch(DEFS, schema, value) ? 'border-black/10' : 'border-red-700/60'
|
||||
}`}
|
||||
placeholder="Enter text"
|
||||
onChange={event => {
|
||||
const newValue: string = event.currentTarget.value
|
||||
setValue(newValue)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
case 'number': {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
className={`rounded-full w-40 px-2 bg-transparent border leading-170 h-6 py-px disabled:opacity-50 read-only:opacity-75 read-only:cursor-not-allowed ${
|
||||
jsonSchema.isMatch(DEFS, schema, value) ? 'border-black/10' : 'border-red-700/60'
|
||||
}`}
|
||||
placeholder="Enter number"
|
||||
onChange={event => {
|
||||
const newValue: number = event.currentTarget.valueAsNumber
|
||||
if (Number.isFinite(newValue)) {
|
||||
setValue(newValue)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'integer': {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
className={`rounded-full w-40 px-2 bg-transparent border leading-170 h-6 py-px disabled:opacity-50 read-only:opacity-75 read-only:cursor-not-allowed ${
|
||||
jsonSchema.isMatch(DEFS, schema, value) ? 'border-black/10' : 'border-red-700/60'
|
||||
}`}
|
||||
placeholder="Enter integer"
|
||||
onChange={event => {
|
||||
const newValue: number = Math.floor(event.currentTarget.valueAsNumber)
|
||||
if (Number.isFinite(newValue)) {
|
||||
setValue(newValue)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'boolean': {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
readOnly={readOnly}
|
||||
checked={typeof value === 'boolean' && value}
|
||||
onChange={event => {
|
||||
setValue(event.currentTarget.checked)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'object': {
|
||||
const propertiesObject =
|
||||
'properties' in schema ? object.asObject(schema.properties) ?? {} : {}
|
||||
const requiredProperties =
|
||||
'required' in schema && Array.isArray(schema.required) ? schema.required : []
|
||||
const propertyDefinitions = Object.entries(propertiesObject).flatMap(
|
||||
(kv: [string, unknown]) => {
|
||||
const [k, v] = kv
|
||||
return object
|
||||
.singletonObjectOrNull(v)
|
||||
.map(childSchema => ({ key: k, schema: childSchema }))
|
||||
}
|
||||
)
|
||||
return constantValue(schema).length === 1 ? null : (
|
||||
<div className="flex flex-col gap-1 rounded-2xl border border-black/10 p-2">
|
||||
{propertyDefinitions.map(definition => {
|
||||
const { key, schema: childSchema } = definition
|
||||
const isOptional = !requiredProperties.includes(key)
|
||||
return constantValue(childSchema).length === 1 ? null : (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-wrap items-center"
|
||||
{...('description' in childSchema
|
||||
? { title: String(childSchema.description) }
|
||||
: {})}
|
||||
>
|
||||
<div
|
||||
className={`inline-block h-6 leading-170 py-px w-28 whitespace-nowrap ${
|
||||
isOptional ? 'cursor-pointer' : ''
|
||||
} ${value != null && key in value ? '' : 'opacity-50'}`}
|
||||
onClick={() => {
|
||||
if (isOptional) {
|
||||
setValue(oldValue => {
|
||||
if (oldValue != null && key in oldValue) {
|
||||
// This is SAFE, as `value` is an untyped object.
|
||||
// The removed key is intentionally unused.
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
|
||||
const { [key]: removed, ...newValue } = oldValue as Record<
|
||||
string,
|
||||
NonNullable<unknown> | null
|
||||
>
|
||||
return newValue
|
||||
} else {
|
||||
return { ...oldValue, [key]: constantValue(childSchema, true)[0] }
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{'title' in childSchema ? String(childSchema.title) : key}
|
||||
</div>
|
||||
{value != null && key in value && (
|
||||
<DataLinkInput
|
||||
readOnly={readOnly}
|
||||
schema={childSchema}
|
||||
// This is SAFE, as `value` is an untyped object.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
value={(value as Record<string, unknown>)[key] ?? null}
|
||||
setValue={newValue => {
|
||||
setValue(oldValue =>
|
||||
typeof oldValue === 'object' &&
|
||||
oldValue != null &&
|
||||
// This is SAFE; but there is no way to tell TypeScript that an object
|
||||
// has an index signature.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(oldValue as Record<string, unknown>)[key] === newValue
|
||||
? oldValue
|
||||
: { ...oldValue, [key]: newValue }
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
default: {
|
||||
// This is a type we don't care about.
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
} else if ('$ref' in schema) {
|
||||
const referencedSchema = jsonSchema.lookupDef(DEFS, schema)
|
||||
if (referencedSchema == null) {
|
||||
return <></>
|
||||
} else {
|
||||
return (
|
||||
<DataLinkInput
|
||||
key={String(schema.$ref)}
|
||||
readOnly={readOnly}
|
||||
schema={referencedSchema}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else if ('anyOf' in schema) {
|
||||
if (!Array.isArray(schema.anyOf)) {
|
||||
return <></>
|
||||
} else {
|
||||
const childSchemas = schema.anyOf.flatMap(object.singletonObjectOrNull)
|
||||
const selectedChildSchema =
|
||||
selectedChildIndex == null ? null : childSchemas[selectedChildIndex]
|
||||
const childValue = selectedChildSchema == null ? [] : constantValue(selectedChildSchema)
|
||||
if (
|
||||
value != null &&
|
||||
(selectedChildSchema == null ||
|
||||
!jsonSchema.isMatch(DEFS, selectedChildSchema, value, { partial: true }))
|
||||
) {
|
||||
const newIndex = childSchemas.findIndex(childSchema =>
|
||||
jsonSchema.isMatch(DEFS, childSchema, value, { partial: true })
|
||||
)
|
||||
if (newIndex !== -1 && newIndex !== selectedChildIndex) {
|
||||
setSelectedChildIndex(newIndex)
|
||||
}
|
||||
}
|
||||
const dropdown = (
|
||||
<Dropdown
|
||||
readOnly={readOnly}
|
||||
items={childSchemas}
|
||||
selectedIndex={selectedChildIndex}
|
||||
render={childProps => getSchemaName(childProps.item)}
|
||||
className="self-start"
|
||||
onClick={(childSchema, index) => {
|
||||
setSelectedChildIndex(index)
|
||||
const newConstantValue = constantValue(childSchema, true)
|
||||
setValue(newConstantValue[0] ?? null)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${childValue.length === 0 ? 'w-full' : ''}`}>
|
||||
{dropdownTitle != null ? (
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-6 py-1">{dropdownTitle}</div>
|
||||
{dropdown}
|
||||
</div>
|
||||
) : (
|
||||
dropdown
|
||||
)}
|
||||
{selectedChildSchema != null && (
|
||||
<DataLinkInput
|
||||
key={selectedChildIndex}
|
||||
readOnly={readOnly}
|
||||
schema={selectedChildSchema}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
} else if ('allOf' in schema) {
|
||||
if (!Array.isArray(schema.allOf)) {
|
||||
return <></>
|
||||
} else {
|
||||
const childSchemas = schema.allOf.flatMap(object.singletonObjectOrNull)
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{childSchemas.map((childSchema, i) => (
|
||||
<DataLinkInput
|
||||
key={i}
|
||||
readOnly={readOnly}
|
||||
schema={childSchema}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return <></>
|
||||
}
|
||||
}
|
@ -5,11 +5,11 @@ import SCHEMA from '#/data/dataLinkSchema.json' assert { type: 'json' }
|
||||
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import DataLinkInput from '#/layouts/DataLinkInput'
|
||||
|
||||
import DataLinkInput from '#/components/dashboard/DataLinkInput'
|
||||
import Modal from '#/components/Modal'
|
||||
|
||||
import * as jsonSchema from '#/utilities/jsonSchema'
|
||||
import * as validateDataLink from '#/utilities/validateDataLink'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -34,10 +34,7 @@ export default function UpsertDataLinkModal(props: UpsertDataLinkModalProps) {
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const [name, setName] = React.useState('')
|
||||
const [value, setValue] = React.useState<NonNullable<unknown> | null>(INITIAL_DATA_LINK_VALUE)
|
||||
const isValueSubmittable = React.useMemo(
|
||||
() => jsonSchema.isMatch(DEFS, SCHEMA.$defs.DataLink, value),
|
||||
[value]
|
||||
)
|
||||
const isValueSubmittable = React.useMemo(() => validateDataLink.validateDataLink(value), [value])
|
||||
const isSubmittable = name !== '' && isValueSubmittable
|
||||
|
||||
return (
|
||||
|
@ -1,9 +1,16 @@
|
||||
/** @file Tests for JSON schema utility functions. */
|
||||
import * as fc from '@fast-check/vitest'
|
||||
import Ajv from 'ajv/dist/2020'
|
||||
import * as v from 'vitest'
|
||||
|
||||
import * as jsonSchema from '#/utilities/jsonSchema'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const AJV = new Ajv()
|
||||
|
||||
// =============
|
||||
// === Tests ===
|
||||
// =============
|
||||
@ -19,7 +26,7 @@ fc.test.prop({
|
||||
`\`${JSON.stringify(value)}\` should round trip to schema and back`
|
||||
).toEqual(value)
|
||||
v.expect(
|
||||
jsonSchema.isMatch({}, schema, value),
|
||||
AJV.validate(schema, value),
|
||||
`\`${JSON.stringify(value)}\` should match its converted schema`
|
||||
).toBe(true)
|
||||
}
|
||||
@ -37,7 +44,7 @@ v.test.each([{ value: JSON.parse('{"__proto__":{}}') }])(
|
||||
`\`${JSON.stringify(value)}\` should round trip to schema and back`
|
||||
).toEqual(value)
|
||||
v.expect(
|
||||
jsonSchema.isMatch({}, schema, value),
|
||||
AJV.validate(schema, value),
|
||||
`\`${JSON.stringify(value)}\` should match its converted schema`
|
||||
).toBe(true)
|
||||
}
|
||||
@ -47,8 +54,8 @@ v.test.each([{ value: JSON.parse('{"__proto__":{}}') }])(
|
||||
const STRING_SCHEMA = { type: 'string' } as const
|
||||
fc.test.prop({ value: fc.fc.string() })('string schema', ({ value }) => {
|
||||
const constSchema = { const: value, type: 'string' }
|
||||
v.expect(jsonSchema.isMatch({}, STRING_SCHEMA, value)).toBe(true)
|
||||
v.expect(jsonSchema.isMatch({}, constSchema, value)).toBe(true)
|
||||
v.expect(AJV.validate(STRING_SCHEMA, value)).toBe(true)
|
||||
v.expect(AJV.validate(constSchema, value)).toBe(true)
|
||||
v.expect(jsonSchema.constantValue({}, constSchema)[0]).toBe(value)
|
||||
})
|
||||
|
||||
@ -56,52 +63,54 @@ const NUMBER_SCHEMA = { type: 'number' } as const
|
||||
fc.test.prop({ value: fc.fc.float() })('number schema', ({ value }) => {
|
||||
if (Number.isFinite(value)) {
|
||||
const constSchema = { const: value, type: 'number' }
|
||||
v.expect(jsonSchema.isMatch({}, NUMBER_SCHEMA, value)).toBe(true)
|
||||
v.expect(jsonSchema.isMatch({}, constSchema, value)).toBe(true)
|
||||
v.expect(AJV.validate(NUMBER_SCHEMA, value)).toBe(true)
|
||||
v.expect(AJV.validate(constSchema, value)).toBe(true)
|
||||
v.expect(jsonSchema.constantValue({}, constSchema)[0]).toBe(value)
|
||||
}
|
||||
})
|
||||
|
||||
fc.test.prop({ value: fc.fc.float(), multiplier: fc.fc.integer() })(
|
||||
'number multiples',
|
||||
({ value, multiplier }) => {
|
||||
const schema = { type: 'number', multipleOf: value }
|
||||
if (Number.isFinite(value)) {
|
||||
v.expect(jsonSchema.isMatch({}, schema, 0)).toBe(true)
|
||||
v.expect(jsonSchema.isMatch({}, schema, value)).toBe(true)
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
if (Math.abs(value * (multiplier + 0.5)) < Number.MAX_SAFE_INTEGER) {
|
||||
v.expect(jsonSchema.isMatch({}, schema, value * multiplier)).toBe(true)
|
||||
if (value !== 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
v.expect(jsonSchema.isMatch({}, schema, value * (multiplier + 0.5))).toBe(false)
|
||||
}
|
||||
fc.test.prop({
|
||||
value: fc.fc.float().filter(n => n > 0),
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
multiplier: fc.fc.integer({ min: -1_000_000, max: 1_000_000 }),
|
||||
})('number multiples', ({ value, multiplier }) => {
|
||||
const schema = { type: 'number', multipleOf: value }
|
||||
if (Number.isFinite(value)) {
|
||||
v.expect(AJV.validate(schema, 0)).toBe(true)
|
||||
v.expect(AJV.validate(schema, value)).toBe(true)
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
if (Math.abs(value * (multiplier + 0.5)) < Number.MAX_SAFE_INTEGER) {
|
||||
v.expect(AJV.validate(schema, value * multiplier)).toBe(true)
|
||||
if (value !== 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
v.expect(AJV.validate(schema, value * (multiplier + 0.5))).toBe(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const INTEGER_SCHEMA = { type: 'integer' } as const
|
||||
fc.test.prop({ value: fc.fc.integer() })('integer schema', ({ value }) => {
|
||||
const constSchema = { const: value, type: 'integer' }
|
||||
v.expect(jsonSchema.isMatch({}, INTEGER_SCHEMA, value)).toBe(true)
|
||||
v.expect(jsonSchema.isMatch({}, constSchema, value)).toBe(true)
|
||||
v.expect(AJV.validate(INTEGER_SCHEMA, value)).toBe(true)
|
||||
v.expect(AJV.validate(constSchema, value)).toBe(true)
|
||||
v.expect(jsonSchema.constantValue({}, constSchema)[0]).toBe(value)
|
||||
})
|
||||
|
||||
fc.test.prop({ value: fc.fc.integer(), multiplier: fc.fc.integer() })(
|
||||
'integer multiples',
|
||||
({ value, multiplier }) => {
|
||||
const schema = { type: 'integer', multipleOf: value }
|
||||
v.expect(jsonSchema.isMatch({}, schema, 0)).toBe(true)
|
||||
v.expect(jsonSchema.isMatch({}, schema, value)).toBe(true)
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
if (Math.abs(value * (multiplier + 0.5)) < Number.MAX_SAFE_INTEGER) {
|
||||
v.expect(jsonSchema.isMatch({}, schema, value * multiplier)).toBe(true)
|
||||
if (value !== 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
v.expect(jsonSchema.isMatch({}, schema, value * (multiplier + 0.5))).toBe(false)
|
||||
}
|
||||
fc.test.prop({
|
||||
value: fc.fc.integer().filter(n => n > 0),
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
multiplier: fc.fc.integer({ min: -1_000_000, max: 1_000_000 }),
|
||||
})('integer multiples', ({ value, multiplier }) => {
|
||||
const schema = { type: 'integer', multipleOf: value }
|
||||
v.expect(AJV.validate(schema, 0)).toBe(true)
|
||||
v.expect(AJV.validate(schema, value)).toBe(true)
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
if (Math.abs(value * (multiplier + 0.5)) < Number.MAX_SAFE_INTEGER) {
|
||||
v.expect(AJV.validate(schema, value * multiplier)).toBe(true)
|
||||
if (value !== 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
v.expect(AJV.validate(schema, value * (multiplier + 0.5))).toBe(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
@ -81,3 +81,26 @@ export class UnreachableCaseError extends Error {
|
||||
export function unreachable(value: never): never {
|
||||
throw new UnreachableCaseError(value)
|
||||
}
|
||||
|
||||
// ==============
|
||||
// === assert ===
|
||||
// ==============
|
||||
|
||||
/** Assert that a value is truthy.
|
||||
* @throws {Error} when the value is not truthy. */
|
||||
// These literals are REQUIRED, as they are falsy.
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers, no-restricted-syntax
|
||||
export function assert<T>(makeValue: () => T | '' | 0 | 0n | false | null | undefined): T {
|
||||
const result = makeValue()
|
||||
// This function explicitly checks for truthiness.
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
'Assertion failed: `' +
|
||||
makeValue.toString().replace(/^\s*[(].*?[)]\s*=>\s*/, '') +
|
||||
'` should not be `null`.'
|
||||
)
|
||||
} else {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
/** @file Utilities for using JSON schemas. */
|
||||
|
||||
import * as objectModule from '#/utilities/object'
|
||||
|
||||
// =================
|
||||
@ -13,6 +12,55 @@ export function lookupDef(defs: Record<string, object>, schema: object) {
|
||||
return name == null ? null : objectModule.asObject(defs[name])
|
||||
}
|
||||
|
||||
// =====================
|
||||
// === getSchemaName ===
|
||||
// =====================
|
||||
|
||||
const SCHEMA_NAMES = new WeakMap<object, string>()
|
||||
|
||||
/** Return a human-readable name representing a schema. */
|
||||
function getSchemaNameHelper(defs: Record<string, object>, schema: object): string {
|
||||
if ('title' in schema) {
|
||||
return String(schema.title)
|
||||
} else if ('type' in schema) {
|
||||
return String(schema.type)
|
||||
} else if ('$ref' in schema) {
|
||||
const referencedSchema = lookupDef(defs, schema)
|
||||
return referencedSchema == null ? '(unknown)' : getSchemaName(defs, referencedSchema)
|
||||
} else if ('anyOf' in schema) {
|
||||
const members = Array.isArray(schema.anyOf) ? schema.anyOf : []
|
||||
return (
|
||||
members
|
||||
.flatMap(objectModule.singletonObjectOrNull)
|
||||
.map(childSchema => getSchemaName(defs, childSchema))
|
||||
.join(' | ') || '(unknown)'
|
||||
)
|
||||
} else if ('allOf' in schema) {
|
||||
const members = Array.isArray(schema.allOf) ? schema.allOf : []
|
||||
return (
|
||||
members
|
||||
.flatMap(objectModule.singletonObjectOrNull)
|
||||
.map(childSchema => getSchemaName(defs, childSchema))
|
||||
.join(' & ') || '(unknown)'
|
||||
)
|
||||
} else {
|
||||
return '(unknown)'
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a human-readable name representing a schema.
|
||||
* This function is a memoized version of {@link getSchemaNameHelper}. */
|
||||
export function getSchemaName(defs: Record<string, object>, schema: object) {
|
||||
const cached = SCHEMA_NAMES.get(schema)
|
||||
if (cached != null) {
|
||||
return cached
|
||||
} else {
|
||||
const name = getSchemaNameHelper(defs, schema)
|
||||
SCHEMA_NAMES.set(schema, name)
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// =============================
|
||||
// === constantValueToSchema ===
|
||||
// =============================
|
||||
@ -38,7 +86,13 @@ export function constantValueToSchema(value: unknown): object | null {
|
||||
result = { type: 'null' }
|
||||
} else if (Array.isArray(value)) {
|
||||
const prefixItems: object[] = []
|
||||
result = { type: 'array', prefixItems, items: false }
|
||||
result = {
|
||||
type: 'array',
|
||||
...(value.length === 0 ? {} : { prefixItems }),
|
||||
minItems: value.length,
|
||||
maxItems: value.length,
|
||||
items: false,
|
||||
}
|
||||
for (const child of value) {
|
||||
const schema = constantValueToSchema(child)
|
||||
if (schema == null) {
|
||||
@ -82,152 +136,205 @@ export function constantValueToSchema(value: unknown): object | null {
|
||||
// === constantValue ===
|
||||
// =====================
|
||||
|
||||
const CONSTANT_VALUE = new WeakMap<object, [] | [NonNullable<unknown> | null]>()
|
||||
const PARTIAL_CONSTANT_VALUE = new WeakMap<object, [] | [NonNullable<unknown> | null]>()
|
||||
const CONSTANT_VALUE = new WeakMap<object, readonly [] | readonly [NonNullable<unknown> | null]>()
|
||||
const PARTIAL_CONSTANT_VALUE = new WeakMap<
|
||||
object,
|
||||
readonly [] | readonly [NonNullable<unknown> | null]
|
||||
>()
|
||||
const SINGLETON_NULL = Object.freeze([null] as const)
|
||||
const EMPTY_ARRAY = Object.freeze([] as const)
|
||||
|
||||
// FIXME: Adjust to allow `type` and `anyOf` and `allOf` and `$ref` to all be present
|
||||
/** The value of the schema, if it can only have one possible value. */
|
||||
function constantValueHelper(
|
||||
defs: Record<string, object>,
|
||||
schema: object,
|
||||
partial = false
|
||||
): [] | [NonNullable<unknown> | null] {
|
||||
let result: [] | [NonNullable<unknown> | null]
|
||||
): readonly [] | readonly [NonNullable<unknown> | null] {
|
||||
if ('const' in schema) {
|
||||
result = [schema.const ?? null]
|
||||
} else if ('type' in schema) {
|
||||
switch (schema.type) {
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'integer':
|
||||
case 'boolean': {
|
||||
// These should already be covered by the `const` check above.
|
||||
result = []
|
||||
if (partial) {
|
||||
switch (schema.type) {
|
||||
case 'string': {
|
||||
result = ['']
|
||||
break
|
||||
}
|
||||
case 'number':
|
||||
case 'integer': {
|
||||
result = [0]
|
||||
break
|
||||
}
|
||||
case 'boolean': {
|
||||
result = [false]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'null': {
|
||||
result = [null]
|
||||
break
|
||||
}
|
||||
case 'object': {
|
||||
const propertiesObject =
|
||||
'properties' in schema ? objectModule.asObject(schema.properties) ?? {} : {}
|
||||
const required = new Set(
|
||||
'required' in schema && Array.isArray(schema.required) ? schema.required.map(String) : []
|
||||
)
|
||||
const object: Record<string, unknown> = {}
|
||||
result = [object]
|
||||
for (const [key, child] of Object.entries(propertiesObject)) {
|
||||
const childSchema = objectModule.asObject(child)
|
||||
if (childSchema == null || (partial && !required.has(key))) {
|
||||
continue
|
||||
}
|
||||
const value = constantValue(defs, childSchema, partial)
|
||||
if (value.length === 0 && !partial) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
result = []
|
||||
break
|
||||
} else {
|
||||
Object.defineProperty(object, key, { value: value[0] ?? null, enumerable: true })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'array': {
|
||||
if (!('items' in schema) || schema.items !== false) {
|
||||
// This array may contain extra items.
|
||||
result = []
|
||||
break
|
||||
} else if (!('prefixItems' in schema) || !Array.isArray(schema.prefixItems)) {
|
||||
// Invalid format.
|
||||
result = []
|
||||
break
|
||||
} else {
|
||||
const array: unknown[] = []
|
||||
result = [array]
|
||||
for (const childSchema of schema.prefixItems) {
|
||||
const childSchemaObject = objectModule.asObject(childSchema)
|
||||
const childValue =
|
||||
childSchemaObject == null ? [] : constantValue(defs, childSchemaObject, partial)
|
||||
if (childValue.length === 0 && !partial) {
|
||||
result = []
|
||||
break
|
||||
}
|
||||
array.push(childValue[0] ?? null)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
default: {
|
||||
result = []
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if ('$ref' in schema) {
|
||||
const referencedSchema = lookupDef(defs, schema)
|
||||
result = referencedSchema == null ? [] : constantValue(defs, referencedSchema, partial)
|
||||
} else if ('anyOf' in schema) {
|
||||
if (!Array.isArray(schema.anyOf) || (!partial && schema.anyOf.length !== 1)) {
|
||||
result = []
|
||||
} else {
|
||||
const firstMember = objectModule.asObject(schema.anyOf[0])
|
||||
result = firstMember == null ? [] : constantValue(defs, firstMember, partial)
|
||||
}
|
||||
} else if ('allOf' in schema) {
|
||||
if (!Array.isArray(schema.allOf) || schema.allOf.length === 0) {
|
||||
result = []
|
||||
} else {
|
||||
const firstMember = objectModule.asObject(schema.allOf[0])
|
||||
const firstValue = firstMember == null ? [] : constantValue(defs, firstMember, partial)
|
||||
if (firstValue.length === 0) {
|
||||
result = []
|
||||
} else {
|
||||
const intersection = firstValue[0]
|
||||
result = [intersection]
|
||||
for (const child of schema.allOf.slice(1)) {
|
||||
const childSchema = objectModule.asObject(child)
|
||||
if (childSchema == null) {
|
||||
continue
|
||||
}
|
||||
const value = constantValue(defs, childSchema, partial)
|
||||
if (value.length === 0 && !partial) {
|
||||
result = []
|
||||
break
|
||||
} else if (typeof intersection !== 'object' || intersection == null) {
|
||||
if (intersection !== value[0] && !partial) {
|
||||
result = []
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if (value[0] == null || (typeof intersection !== typeof value[0] && !partial)) {
|
||||
result = []
|
||||
break
|
||||
}
|
||||
Object.assign(intersection, value[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [schema.const ?? null]
|
||||
} else {
|
||||
result = []
|
||||
const invalid: readonly [] | readonly [NonNullable<unknown> | null] = partial
|
||||
? SINGLETON_NULL
|
||||
: EMPTY_ARRAY
|
||||
const results: (NonNullable<unknown> | null)[] = []
|
||||
if ('type' in schema) {
|
||||
switch (schema.type) {
|
||||
case 'null': {
|
||||
results.push(null)
|
||||
break
|
||||
}
|
||||
case 'object': {
|
||||
const propertiesObject =
|
||||
'properties' in schema ? objectModule.asObject(schema.properties) ?? {} : {}
|
||||
const required = new Set(
|
||||
'required' in schema && Array.isArray(schema.required)
|
||||
? schema.required.map(String)
|
||||
: []
|
||||
)
|
||||
const object: Record<string, unknown> = {}
|
||||
results.push(object)
|
||||
for (const [key, child] of Object.entries(propertiesObject)) {
|
||||
const childSchema = objectModule.asObject(child)
|
||||
if (childSchema == null || (partial && !required.has(key))) {
|
||||
continue
|
||||
}
|
||||
const value = constantValue(defs, childSchema, partial)
|
||||
if (value.length === 0 && !partial) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return invalid
|
||||
} else {
|
||||
Object.defineProperty(object, key, { value: value[0] ?? null, enumerable: true })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'array': {
|
||||
if (!partial && (!('items' in schema) || schema.items !== false)) {
|
||||
// This array may contain extra items.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return invalid
|
||||
} else if (!('prefixItems' in schema) || !Array.isArray(schema.prefixItems)) {
|
||||
results.push([])
|
||||
break
|
||||
} else {
|
||||
const array: unknown[] = []
|
||||
results.push(array)
|
||||
for (const childSchema of schema.prefixItems) {
|
||||
const childSchemaObject = objectModule.asObject(childSchema)
|
||||
const childValue =
|
||||
childSchemaObject == null ? [] : constantValue(defs, childSchemaObject, partial)
|
||||
if (childValue.length === 0 && !partial) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return invalid
|
||||
}
|
||||
array.push(childValue[0] ?? null)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ('$ref' in schema) {
|
||||
const referencedSchema = lookupDef(defs, schema)
|
||||
if (referencedSchema == null) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return invalid
|
||||
} else {
|
||||
const value = constantValue(defs, referencedSchema, partial)
|
||||
if (!partial && value.length === 0) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return invalid
|
||||
}
|
||||
if (value.length === 1) {
|
||||
results.push(value[0])
|
||||
}
|
||||
}
|
||||
} else if ('anyOf' in schema) {
|
||||
if (!Array.isArray(schema.anyOf) || (!partial && schema.anyOf.length !== 1)) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return invalid
|
||||
} else {
|
||||
const firstMember = objectModule.asObject(schema.anyOf[0])
|
||||
if (firstMember == null) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return invalid
|
||||
} else {
|
||||
const value = constantValue(defs, firstMember, partial)
|
||||
if (!partial && value.length === 0) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return invalid
|
||||
}
|
||||
if (value.length === 1) {
|
||||
results.push(value[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ('allOf' in schema && Array.isArray(schema.allOf)) {
|
||||
if (schema.allOf.length === 0) {
|
||||
return invalid
|
||||
} else {
|
||||
for (const childSchema of schema.allOf) {
|
||||
const schemaObject = objectModule.asObject(childSchema)
|
||||
const value = schemaObject == null ? [] : constantValue(defs, schemaObject, partial)
|
||||
if (!partial && value.length === 0) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return invalid
|
||||
}
|
||||
if (value.length === 1) {
|
||||
results.push(value[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (partial && results.length === 0) {
|
||||
if ('type' in schema) {
|
||||
switch (schema.type) {
|
||||
case 'string': {
|
||||
return ['']
|
||||
}
|
||||
case 'number':
|
||||
case 'integer': {
|
||||
return [0]
|
||||
}
|
||||
case 'boolean': {
|
||||
return [true]
|
||||
}
|
||||
default: {
|
||||
return SINGLETON_NULL
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return SINGLETON_NULL
|
||||
}
|
||||
} else if (results.length === 0) {
|
||||
return invalid
|
||||
} else {
|
||||
const result = results[0] ?? null
|
||||
let resultArray: readonly [] | readonly [NonNullable<unknown> | null] = [result]
|
||||
for (const child of results.slice(1)) {
|
||||
const childSchema = objectModule.asObject(child)
|
||||
if (childSchema == null) {
|
||||
continue
|
||||
}
|
||||
const value = constantValue(defs, childSchema, partial)
|
||||
if (value.length === 0 && !partial) {
|
||||
resultArray = []
|
||||
break
|
||||
} else if (typeof result !== 'object' || result == null) {
|
||||
if (result !== value[0] && !partial) {
|
||||
resultArray = []
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if (value[0] == null || (typeof result !== typeof value[0] && !partial)) {
|
||||
resultArray = []
|
||||
break
|
||||
}
|
||||
Object.assign(result, value[0])
|
||||
}
|
||||
}
|
||||
if (partial && 'type' in schema) {
|
||||
switch (schema.type) {
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'boolean': {
|
||||
return typeof resultArray[0] === schema.type ? resultArray : invalid
|
||||
}
|
||||
case 'integer': {
|
||||
return typeof resultArray[0] === 'number' && Number.isInteger(resultArray[0])
|
||||
? resultArray
|
||||
: invalid
|
||||
}
|
||||
default: {
|
||||
return resultArray
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return resultArray
|
||||
}
|
||||
}
|
||||
}
|
||||
return partial && result.length === 0 ? [null] : result
|
||||
}
|
||||
|
||||
/** The value of the schema, if it can only have one possible value.
|
||||
@ -243,244 +350,3 @@ export function constantValue(defs: Record<string, object>, schema: object, part
|
||||
return renderable
|
||||
}
|
||||
}
|
||||
|
||||
// ===============
|
||||
// === isMatch ===
|
||||
// ===============
|
||||
|
||||
/** Options for {@link isMatch}. */
|
||||
export interface MatchOptions {
|
||||
/** If true, accept a match where one or more members are `null`, `undefined`, or not present. */
|
||||
readonly partial?: boolean
|
||||
}
|
||||
|
||||
/** Attempt to construct a RegExp from the given pattern. If that fails, return a regex that matches
|
||||
* any string. */
|
||||
function tryRegExp(pattern: string) {
|
||||
try {
|
||||
return new RegExp(pattern)
|
||||
} catch {
|
||||
return new RegExp('')
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the value complies with the schema.. */
|
||||
export function isMatch(
|
||||
defs: Record<string, object>,
|
||||
schema: object,
|
||||
value: unknown,
|
||||
options: MatchOptions = {}
|
||||
): boolean {
|
||||
const { partial = false } = options
|
||||
let result: boolean
|
||||
if (partial && value == null) {
|
||||
result = true
|
||||
} else if ('const' in schema) {
|
||||
result = schema.const === value
|
||||
} else if ('type' in schema) {
|
||||
switch (schema.type) {
|
||||
case 'string': {
|
||||
// https://json-schema.org/understanding-json-schema/reference/string
|
||||
if (typeof value !== 'string') {
|
||||
result = false
|
||||
} else if (partial && value === '') {
|
||||
result = true
|
||||
} else if (
|
||||
'minLength' in schema &&
|
||||
typeof schema.minLength === 'number' &&
|
||||
value.length < schema.minLength
|
||||
) {
|
||||
result = false
|
||||
} else if (
|
||||
'maxLength' in schema &&
|
||||
typeof schema.maxLength === 'number' &&
|
||||
value.length > schema.maxLength
|
||||
) {
|
||||
result = false
|
||||
} else if (
|
||||
'pattern' in schema &&
|
||||
typeof schema.pattern === 'string' &&
|
||||
!tryRegExp(schema.pattern).test(value)
|
||||
) {
|
||||
result = false
|
||||
} else {
|
||||
const format =
|
||||
'format' in schema && typeof schema.format === 'string' ? schema.format : null
|
||||
// `format` validation has been omitted as it is currently not needed, and quite complex
|
||||
// to correctly validate.
|
||||
// https://json-schema.org/understanding-json-schema/reference/string#built-in-formats
|
||||
result = true
|
||||
switch (format) {
|
||||
case null:
|
||||
default: {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'number':
|
||||
case 'integer': {
|
||||
// https://json-schema.org/understanding-json-schema/reference/numeric
|
||||
if (typeof value !== 'number') {
|
||||
result = false
|
||||
} else if (partial && value === 0) {
|
||||
result = true
|
||||
} else if (schema.type === 'integer' && !Number.isInteger(value)) {
|
||||
result = false
|
||||
} else if (
|
||||
'multipleOf' in schema &&
|
||||
typeof schema.multipleOf === 'number' &&
|
||||
value !== 0 &&
|
||||
value % schema.multipleOf !== 0 &&
|
||||
// Should be mostly equivalent to `%`, except more robust for multiple detection
|
||||
// in some cases like`1 % 0.01`.
|
||||
value - schema.multipleOf * Math.round(value / schema.multipleOf) !== 0
|
||||
) {
|
||||
result = false
|
||||
} else if (
|
||||
'minimum' in schema &&
|
||||
typeof schema.minimum === 'number' &&
|
||||
value < schema.minimum
|
||||
) {
|
||||
result = false
|
||||
} else if (
|
||||
'exclusiveMinimum' in schema &&
|
||||
typeof schema.exclusiveMinimum === 'number' &&
|
||||
value <= schema.exclusiveMinimum
|
||||
) {
|
||||
result = false
|
||||
} else if (
|
||||
'maximum' in schema &&
|
||||
typeof schema.maximum === 'number' &&
|
||||
value > schema.maximum
|
||||
) {
|
||||
result = false
|
||||
} else if (
|
||||
'exclusiveMaximum' in schema &&
|
||||
typeof schema.exclusiveMaximum === 'number' &&
|
||||
value >= schema.exclusiveMaximum
|
||||
) {
|
||||
result = false
|
||||
} else {
|
||||
result = true
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'boolean': {
|
||||
result = typeof value === 'boolean'
|
||||
break
|
||||
}
|
||||
case 'null': {
|
||||
// This MUST only match `null` and not `undefined`.
|
||||
// eslint-disable-next-line eqeqeq
|
||||
result = value === null
|
||||
break
|
||||
}
|
||||
case 'object': {
|
||||
if (typeof value !== 'object' || value == null) {
|
||||
result = false
|
||||
} else {
|
||||
// This is SAFE, since arbitrary properties are technically valid on objects.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const valueObject = value as Record<string, unknown>
|
||||
const propertiesObject =
|
||||
'properties' in schema ? objectModule.asObject(schema.properties) ?? {} : {}
|
||||
const required = new Set(
|
||||
'required' in schema && Array.isArray(schema.required)
|
||||
? schema.required.map(String)
|
||||
: []
|
||||
)
|
||||
result = Object.entries(propertiesObject).every(kv => {
|
||||
// This is SAFE, as it is safely converted to an `object` on the next line.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const [key, childSchema] = kv
|
||||
const childSchemaObject = objectModule.asObject(childSchema)
|
||||
return (
|
||||
(key in valueObject &&
|
||||
childSchemaObject != null &&
|
||||
isMatch(defs, childSchemaObject, valueObject[key], options)) ||
|
||||
(!(key in valueObject) && !required.has(key))
|
||||
)
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'array': {
|
||||
let startIndex = 0
|
||||
const doPrefixItemsMatch = (prefixItems: unknown[], arrayValue: unknown[]) => {
|
||||
startIndex += prefixItems.length
|
||||
result = true
|
||||
for (let i = 0; i < prefixItems.length; i += 1) {
|
||||
const childSchema = prefixItems[i]
|
||||
if (
|
||||
typeof childSchema === 'object' &&
|
||||
childSchema != null &&
|
||||
!isMatch(defs, childSchema, arrayValue[i], options)
|
||||
) {
|
||||
result = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
result = false
|
||||
break
|
||||
} else if (
|
||||
'prefixItems' in schema &&
|
||||
Array.isArray(schema.prefixItems) &&
|
||||
!doPrefixItemsMatch(schema.prefixItems, value)
|
||||
) {
|
||||
result = false
|
||||
break
|
||||
} else if ('items' in schema && schema.items === false && startIndex !== value.length) {
|
||||
result = false
|
||||
break
|
||||
} else if ('items' in schema && typeof schema.items === 'object' && schema.items != null) {
|
||||
const childSchema = schema.items
|
||||
result = true
|
||||
for (let i = startIndex; i < value.length; i += 1) {
|
||||
if (!isMatch(defs, childSchema, value[i], options)) {
|
||||
result = false
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
} else {
|
||||
result = true
|
||||
break
|
||||
}
|
||||
}
|
||||
default: {
|
||||
result = false
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if ('$ref' in schema) {
|
||||
const referencedSchema = lookupDef(defs, schema)
|
||||
result = referencedSchema != null && isMatch(defs, referencedSchema, value, options)
|
||||
} else if ('anyOf' in schema) {
|
||||
if (!Array.isArray(schema.anyOf)) {
|
||||
result = false
|
||||
} else {
|
||||
result = schema.anyOf.some(childSchema => {
|
||||
const childSchemaObject = objectModule.asObject(childSchema)
|
||||
return childSchemaObject != null && isMatch(defs, childSchemaObject, value, options)
|
||||
})
|
||||
}
|
||||
} else if ('allOf' in schema) {
|
||||
if (!Array.isArray(schema.allOf)) {
|
||||
result = false
|
||||
} else {
|
||||
result = schema.allOf.every(childSchema => {
|
||||
const childSchemaObject = objectModule.asObject(childSchema)
|
||||
return childSchemaObject != null && isMatch(defs, childSchemaObject, value, options)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// `enum`s are currently ignored as they are not yet used.
|
||||
result = false
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
@ -0,0 +1,16 @@
|
||||
/** @file Validation functions related to Data Links. */
|
||||
import type * as ajv from 'ajv/dist/2020'
|
||||
import Ajv from 'ajv/dist/2020'
|
||||
|
||||
import SCHEMA from '#/data/dataLinkSchema.json' assert { type: 'json' }
|
||||
|
||||
import * as error from '#/utilities/error'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const AJV = new Ajv({ formats: { 'enso-secret': true } })
|
||||
AJV.addSchema(SCHEMA)
|
||||
// This is a function, even though it does not contain function syntax.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const validateDataLink = error.assert<ajv.ValidateFunction>(() =>
|
||||
AJV.getSchema('#/$defs/DataLink')
|
||||
)
|
5
app/ide-desktop/lib/types/globals.d.ts
vendored
5
app/ide-desktop/lib/types/globals.d.ts
vendored
@ -92,9 +92,4 @@ declare global {
|
||||
const IS_VITE: boolean
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const CLOUD_ENV: 'npekin' | 'pbuchu' | 'production' | undefined
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
/** Only exists in development mode. */
|
||||
// This is a function.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const assert: (invariant: boolean, message: string) => void
|
||||
}
|
||||
|
31
package-lock.json
generated
31
package-lock.json
generated
@ -284,6 +284,7 @@
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@sentry/react": "^7.74.0",
|
||||
"ajv": "^8.12.0",
|
||||
"enso-common": "^1.0.0",
|
||||
"is-network-error": "^1.0.1",
|
||||
"react": "^18.2.0",
|
||||
@ -649,6 +650,21 @@
|
||||
"vite": "^4.2.0 || ^5.0.0"
|
||||
}
|
||||
},
|
||||
"app/ide-desktop/node_modules/ajv": {
|
||||
"version": "8.12.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
|
||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"app/ide-desktop/node_modules/browserslist": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz",
|
||||
@ -724,6 +740,11 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"app/ide-desktop/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
|
||||
},
|
||||
"app/ide-desktop/node_modules/node-releases": {
|
||||
"version": "2.0.14",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
|
||||
@ -9369,7 +9390,6 @@
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
@ -14456,6 +14476,14 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
@ -17194,7 +17222,6 @@
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
|
Loading…
Reference in New Issue
Block a user