Skip to content

DEVXP-2541: feat(next): add support for format #128

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/build-next.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ name: Pull Request (v-next)
on:
pull_request:
paths:
- 'next/**' # Only run if files in next/ directory change
- '.github/workflows/build-next.yml' # Also run if this workflow file changes
- 'next/**' # Only run if files in next/ directory change
- '.github/workflows/build-next.yml' # Also run if this workflow file changes

jobs:
dependencies:
Expand Down Expand Up @@ -45,7 +45,7 @@ jobs:
with:
submodules: true
token: ${{ secrets.GITHUB_TOKEN }}

- uses: pnpm/action-setup@v3
with:
version: 9
Expand All @@ -60,5 +60,8 @@ jobs:
path: next/node_modules
key: ${{ runner.os }}-pnpm-next-${{ hashFiles('next/pnpm-lock.yaml') }}

- name: Run lint and type checks
run: cd next && pnpm check

- name: Tests
run: cd next && pnpm test
run: cd next && pnpm test
13 changes: 12 additions & 1 deletion next/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
import antfu from '@antfu/eslint-config'

export default antfu({})
export default antfu({
ignores: [
// Build output
'dist',

// Dependencies
'node_modules',

// External test suite
'json-schema-test-suite',
],
})
12 changes: 6 additions & 6 deletions next/jest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const roots = [
// '<rootDir>/../src/tests',
// 2. The new tests for this version
'<rootDir>/test',
];
]

/**
* Module aliases to use the same test with different source versions.
Expand All @@ -29,7 +29,7 @@ const moduleNameMapper = {
// Avoid catch all aliases such as "^@/(.*)$".
// Aliases should be added as needed.
// If there are many, we will have a compat barrel file.
};
}

/**
* Some tests are invalid for V2 testing.
Expand All @@ -41,14 +41,14 @@ const moduleNameMapper = {
*/
const testPathIgnorePatterns = [
// Nothing yet
];
]

/** @type {import('jest').Config} */
const config = {
roots,
moduleNameMapper,
testPathIgnorePatterns,
reporters: ['default', '<rootDir>/test/validation/json-schema-test-suite-tracker.js'],
};
reporters: ['default', '<rootDir>/test/json-schema-test-suite/json-schema-test-suite-tracker.js'],
}

export default config;
export default config
4 changes: 4 additions & 0 deletions next/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"name": "@remoteoss/json-schema-form",
"type": "module",
"version": "1.0.0-beta.0",
"packageManager": "pnpm@9.15.2",
"description": "WIP V2 – Headless UI form powered by JSON Schemas",
Expand Down Expand Up @@ -31,6 +32,8 @@
"test:watch": "jest --watchAll",
"test:file": "jest --runTestsByPath",
"lint": "eslint --max-warnings 0 .",
"typecheck": "tsc --noEmit",
"check": "pnpm run lint && pnpm run typecheck",
"release:dev": "cd .. && npm run release:v1:dev",
"release:beta": "cd .. && npm run release:v1:beta"
},
Expand All @@ -40,6 +43,7 @@
"@babel/preset-env": "^7.23.7",
"@babel/preset-typescript": "^7.26.0",
"@jest/globals": "^29.7.0",
"@jest/reporters": "^29.7.0",
"babel-jest": "^29.7.0",
"eslint": "^9.18.0",
"generate-changelog": "^1.8.0",
Expand Down
3 changes: 3 additions & 0 deletions next/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion next/src/field/object.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { JsfObjectSchema, JsfSchema } from '../types'
import type { JsfObjectSchema } from '../types'
import type { Field } from './type'
import { setCustomOrder } from '../custom/order'
import { buildFieldSchema } from './schema'
Expand Down
139 changes: 139 additions & 0 deletions next/src/validation/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { ValidationError } from '../form'
import type { SchemaValidationErrorType } from './schema'

/**
* Format validation error type
* @description
* According to JSON Schema 2020-12, format validation is an annotation by default.
* It should only be treated as an assertion when explicitly configured.
*/
export type FormatValidationErrorType = 'format'

/**
* Supported format names as defined in JSON Schema 2020-12
*/
export type SupportedFormat =
| 'date-time'
| 'date'
| 'time'
| 'duration'
| 'email'
| 'idn-email'
| 'hostname'
| 'idn-hostname'
| 'ipv4'
| 'ipv6'
| 'uri'
| 'uri-reference'
| 'iri'
| 'iri-reference'
| 'uuid'

// Cache compiled RegExp objects for performance
const REGEX = {
dateTime: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/,
date: /^\d{4}-\d{2}-\d{2}$/,
time: /^\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/,
duration: /^P(?:\d+Y)?(?:\d+M)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$/,
// RFC 5322 compliant email regex
email: /^[\w.!#$%&'*+/=?^`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i,
// RFC 1123 compliant hostname regex
hostname: /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i,
ipv4: /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d{1,2})\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d{1,2})$/,
ipv6: /^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(?::[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(?:ffff(?::0{1,4})?:)?(?:(?:25[0-5]|(?:2[0-4]|1?\d)?\d)\.){3}(?:25[0-5]|(?:2[0-4]|1?\d)?\d)|(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|(?:2[0-4]|1?\d)?\d)\.){3}(?:25[0-5]|(?:2[0-4]|1?\d)?\d))$/,
uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
}

/**
* Built-in format validators
* @description
* According to JSON Schema 2020-12, implementations SHOULD implement validation
* for these formats, but MAY choose to implement validation as a no-op.
*/
const formatValidators: Record<SupportedFormat, (value: string) => boolean> = {
'date-time': value => REGEX.dateTime.test(value),
'date': value => REGEX.date.test(value),
'time': value => REGEX.time.test(value),
'duration': value => REGEX.duration.test(value),
'email': value => REGEX.email.test(value),
'idn-email': value => REGEX.email.test(value), // TODO: Add proper IDN support
'hostname': value => REGEX.hostname.test(value),
'idn-hostname': value => REGEX.hostname.test(value), // TODO: Add proper IDN support
'ipv4': value => REGEX.ipv4.test(value),
'ipv6': value => REGEX.ipv6.test(value),
'uri': (value) => {
try {
void new URL(value)
return true
}
catch {
return false
}
},
'uri-reference': (value) => {
try {
void new URL(value, 'http://example.com')
return true
}
catch {
return false
}
},
'iri': (value) => {
try {
void new URL(value)
return true
}
catch {
return false
}
},
'iri-reference': (value) => {
try {
void new URL(value, 'http://example.com')
return true
}
catch {
return false
}
},
'uuid': value => REGEX.uuid.test(value),
}

/**
* Validate a string value against a format
* @param value - The string value to validate
* @param format - The format to validate against
* @returns An array of validation errors
* @description
* According to JSON Schema 2020-12:
* - Format validation is an annotation by default
* - Format validation only applies to strings
* - Unknown formats should be ignored
* - Implementations SHOULD implement validation for standard formats
* - Implementations MAY treat format as a no-op
* @example
* ```ts
* const errors = validateFormat('not-an-email', 'email')
* console.log(errors) // [{ path: [], validation: 'format', message: 'must be a valid email format' }]
* ```
*/
export function validateFormat(value: string, format: string): ValidationError[] {
const errors: ValidationError[] = []

// Format validation only applies to strings
if (typeof value !== 'string') {
return errors
}

const validator = formatValidators[format as SupportedFormat]
if (validator && !validator(value)) {
errors.push({
path: [],
validation: 'format' as SchemaValidationErrorType,
message: `must be a valid ${format} format`,
})
}

return errors
}
5 changes: 5 additions & 0 deletions next/src/validation/schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ValidationError } from '../form'
import type { JsfSchema, JsfSchemaType, SchemaValue } from '../types'
import type { ObjectValidationErrorType } from './object'
import type { StringValidationErrorType } from './string'
import { validateAnyOf } from './anyOf'
import { validateObject } from './object'
import { validateString } from './string'
Expand All @@ -26,6 +27,10 @@ export type SchemaValidationErrorType =
* The value fails validation against anyOf subschemas
*/
| 'anyOf'
/**
* The value fails string validation
*/
| StringValidationErrorType

/**
* Get the type of a schema
Expand Down
10 changes: 10 additions & 0 deletions next/src/validation/string.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ValidationError } from '../form'
import type { NonBooleanJsfSchema, SchemaValue } from '../types'
import { validateFormat } from './format'
import { getSchemaType } from './schema'

export type StringValidationErrorType =
Expand All @@ -15,6 +16,10 @@ export type StringValidationErrorType =
* The value does not match the pattern
*/
| 'pattern'
/**
* The value does not match the format
*/
| 'format'

/**
* Validate a string against a schema
Expand All @@ -24,6 +29,7 @@ export type StringValidationErrorType =
* @description
* - Validates the string length against the `minLength` and `maxLength` properties.
* - Validates the string pattern against a regular expression defined in the `pattern` property.
* - Validates the string format against the `format` property.
*/
export function validateString(value: SchemaValue, schema: NonBooleanJsfSchema): ValidationError[] {
const errors: ValidationError[] = []
Expand All @@ -47,6 +53,10 @@ export function validateString(value: SchemaValue, schema: NonBooleanJsfSchema):
})
}
}

if (schema.format !== undefined) {
errors.push(...validateFormat(value, schema.format))
}
}

return errors
Expand Down
10 changes: 5 additions & 5 deletions next/test/custom/order.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { JsfSchema } from '../../src/types'
import type { JsfObjectSchema } from '../../src/types'
import { describe, expect, it } from '@jest/globals'
import { createHeadlessForm } from '../../src'

describe('custom order', () => {
it('should sort fields by x-jsf-order', () => {
const schema: JsfSchema = {
const schema: JsfObjectSchema = {
'type': 'object',
'properties': {
name: { type: 'string' },
Expand All @@ -19,7 +19,7 @@ describe('custom order', () => {
})

it('should sort nested objects', () => {
const addressSchema: JsfSchema = {
const addressSchema: JsfObjectSchema = {
'type': 'object',
'properties': {
state: { type: 'string' },
Expand All @@ -29,7 +29,7 @@ describe('custom order', () => {
'x-jsf-order': ['street', 'city', 'state'],
}

const mainSchema: JsfSchema = {
const mainSchema: JsfObjectSchema = {
'type': 'object',
'properties': {
address: addressSchema,
Expand All @@ -53,7 +53,7 @@ describe('custom order', () => {
})

it('should respect initial, unspecified order', () => {
const schema: JsfSchema = {
const schema: JsfObjectSchema = {
'type': 'object',
'properties': {
one: { type: 'string' },
Expand Down
1 change: 1 addition & 0 deletions next/test/json-schema-test-suite/constants.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export declare const JSON_SCHEMA_SUITE_FAILED_TESTS_FILE_NAME: string
Loading