Switch to AJV for validating JSON Schema (#9191)

As ~~requested~~ suggested by @radeusgd

# Important Notes
None
This commit is contained in:
somebody1234 2024-02-29 20:36:47 +10:00 committed by GitHub
parent 97033a2ff4
commit 0a28d91d35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 781 additions and 834 deletions

View File

@ -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}===="]
}
}

View File

@ -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]*)+$)/'

View File

@ -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",

View 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>
)
}
}

View File

@ -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}
/>
)
}

View File

@ -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()

View File

@ -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(() => {

View File

@ -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 <></>
}
}

View File

@ -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 (

View File

@ -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)
}
}
)
})

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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')
)

View File

@ -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
View File

@ -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"