feat: basic e2e tests working for api gateway; added json-schema validation and type coercion

pull/715/head
Travis Fischer 2025-06-04 02:57:33 +07:00
rodzic 1215e02bd3
commit e4520c69a4
51 zmienionych plików z 3358 dodań i 129 usunięć

Wyświetl plik

@ -30,7 +30,11 @@ export const consumerTokenParamsSchema = z.object({
})
export const populateConsumerSchema = z.object({
populate: z.array(consumerRelationsSchema).default([]).optional()
populate: z
.union([consumerRelationsSchema, z.array(consumerRelationsSchema)])
.default([])
.transform((p) => (Array.isArray(p) ? p : [p]))
.optional()
})
export const paginationAndPopulateConsumerSchema = z.object({

Wyświetl plik

@ -4,6 +4,7 @@ import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
import type { AuthenticatedEnv } from '@/lib/types'
import { and, db, eq, schema } from '@/db'
import { acl } from '@/lib/acl'
import { ensureAuthUser } from '@/lib/ensure-auth-user'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponses
@ -52,6 +53,9 @@ export function registerV1DeploymentsListDeployments(
const userId = c.get('userId')
const teamMember = c.get('teamMember')
const user = await ensureAuthUser(c)
const isAdmin = user.role === 'admin'
let projectId: string | undefined
if (projectIdentifier) {
@ -64,9 +68,11 @@ export function registerV1DeploymentsListDeployments(
const deployments = await db.query.deployments.findMany({
where: and(
teamMember
? eq(schema.deployments.teamId, teamMember.teamId)
: eq(schema.deployments.userId, userId),
isAdmin
? undefined
: teamMember
? eq(schema.deployments.teamId, teamMember.teamId)
: eq(schema.deployments.userId, userId),
projectId ? eq(schema.deployments.projectId, projectId) : undefined,
deploymentIdentifier
? eq(schema.deployments.identifier, deploymentIdentifier)

Wyświetl plik

@ -28,7 +28,11 @@ export const filterDeploymentSchema = z.object({
})
export const populateDeploymentSchema = z.object({
populate: z.array(deploymentRelationsSchema).default([]).optional()
populate: z
.union([deploymentRelationsSchema, z.array(deploymentRelationsSchema)])
.default([])
.transform((p) => (Array.isArray(p) ? p : [p]))
.optional()
})
export const deploymentIdentifierQuerySchema = z.object({

Wyświetl plik

@ -22,7 +22,11 @@ export const projectIdentifierQuerySchema = z.object({
})
export const populateProjectSchema = z.object({
populate: z.array(projectRelationsSchema).default([]).optional()
populate: z
.union([projectRelationsSchema, z.array(projectRelationsSchema)])
.default([])
.transform((p) => (Array.isArray(p) ? p : [p]))
.optional()
})
export const projectIdentifierAndPopulateSchema = z.object({

Wyświetl plik

@ -1,5 +1,5 @@
import { assert } from '@agentic/platform-core'
import { parseFaasIdentifier, validators } from '@agentic/platform-validators'
import { parseToolIdentifier, validators } from '@agentic/platform-validators'
import { z } from '@hono/zod-openapi'
import type { consumersRelations } from './schema/consumer'
@ -45,17 +45,27 @@ export const logEntryIdSchema = getIdSchemaForModelType('logEntry')
export const projectIdentifierSchema = z
.string()
.refine((id) => validators.projectIdentifier(id), {
message: 'Invalid project identifier'
})
.refine(
(id) =>
validators.projectIdentifier(id) || projectIdSchema.safeParse(id).success,
{
message: 'Invalid project identifier'
}
)
.describe('Public project identifier (e.g. "namespace/project-name")')
.openapi('ProjectIdentifier')
export const deploymentIdentifierSchema = z
.string()
.refine((id) => !!parseFaasIdentifier(id), {
message: 'Invalid deployment identifier'
})
.refine(
(id) =>
!!parseToolIdentifier(id) ||
validators.deploymentIdentifier(id) ||
deploymentIdSchema.safeParse(id).success,
{
message: 'Invalid deployment identifier'
}
)
.describe(
'Public deployment identifier (e.g. "namespace/project-name@{hash|version|latest}")'
)

Wyświetl plik

@ -1,5 +1,5 @@
import { assert } from '@agentic/platform-core'
import { parseFaasIdentifier } from '@agentic/platform-validators'
import { parseToolIdentifier } from '@agentic/platform-validators'
import type { AuthenticatedContext } from '@/lib/types'
import {
@ -11,7 +11,6 @@ import {
schema
} from '@/db'
import { setPublicCacheControl } from '@/lib/cache-control'
import { ensureAuthUser } from '@/lib/ensure-auth-user'
/**
* Attempts to find the Deployment matching the given deployment ID or
@ -36,7 +35,6 @@ export async function tryGetDeploymentByIdentifier(
}
): Promise<RawDeployment> {
assert(deploymentIdentifier, 400, 'Missing required deployment identifier')
const user = await ensureAuthUser(ctx)
// First check if the identifier is a deployment ID
if (deploymentIdSchema.safeParse(deploymentIdentifier).success) {
@ -49,11 +47,7 @@ export async function tryGetDeploymentByIdentifier(
return deployment
}
const teamMember = ctx.get('teamMember')
const namespace = teamMember ? teamMember.teamSlug : user.username
const parsedFaas = parseFaasIdentifier(deploymentIdentifier, {
namespace
})
const parsedFaas = parseToolIdentifier(deploymentIdentifier)
assert(
parsedFaas,
400,
@ -75,21 +69,18 @@ export async function tryGetDeploymentByIdentifier(
return deployment
} else if (version) {
const project = await db.query.projects.findFirst({
...dbQueryOpts,
where: eq(schema.projects.identifier, projectIdentifier)
})
assert(project, 404, `Project not found "${projectIdentifier}"`)
if (version === 'latest') {
assert(
project.lastPublishedDeploymentId,
404,
'Project has no published deployments'
)
const deploymentId =
project.lastPublishedDeploymentId || project.lastDeploymentId
assert(deploymentId, 404, 'Project has no published deployments')
const deployment = await db.query.deployments.findFirst({
...dbQueryOpts,
where: eq(schema.deployments.id, project.lastPublishedDeploymentId)
where: eq(schema.deployments.id, deploymentId)
})
assert(
deployment,

Wyświetl plik

@ -1,9 +1,8 @@
import { assert } from '@agentic/platform-core'
import { parseFaasIdentifier } from '@agentic/platform-validators'
import { parseToolIdentifier } from '@agentic/platform-validators'
import type { AuthenticatedContext } from '@/lib/types'
import { db, eq, projectIdSchema, type RawProject, schema } from '@/db'
import { ensureAuthUser } from '@/lib/ensure-auth-user'
/**
* Attempts to find the Project matching the given ID or identifier.
@ -28,7 +27,6 @@ export async function tryGetProjectByIdentifier(
}
): Promise<RawProject> {
assert(projectIdentifier, 400, 'Missing required project identifier')
const user = await ensureAuthUser(ctx)
// First check if the identifier is a project ID
if (projectIdSchema.safeParse(projectIdentifier).success) {
@ -40,11 +38,7 @@ export async function tryGetProjectByIdentifier(
return project
}
const teamMember = ctx.get('teamMember')
const namespace = teamMember ? teamMember.teamSlug : user.username
const parsedFaas = parseFaasIdentifier(projectIdentifier, {
namespace
})
const parsedFaas = parseToolIdentifier(projectIdentifier)
assert(
parsedFaas?.projectIdentifier,
400,

Wyświetl plik

@ -0,0 +1,61 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`0) GET dev/test-basic-openapi/getPost 1`] = `
{
"body": "quia et suscipit
suscipit recusandae consequuntur expedita et cum
reprehenderit molestiae ut ut quas totam
nostrum rerum est autem sunt rem eveniet architecto",
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"userId": 1,
}
`;
exports[`1) GET dev/test-basic-openapi@b6e21206/getPost?postId=1 1`] = `
{
"body": "quia et suscipit
suscipit recusandae consequuntur expedita et cum
reprehenderit molestiae ut ut quas totam
nostrum rerum est autem sunt rem eveniet architecto",
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"userId": 1,
}
`;
exports[`2) GET dev/test-basic-openapi@b6e21206/getPost 1`] = `
{
"body": "quia et suscipit
suscipit recusandae consequuntur expedita et cum
reprehenderit molestiae ut ut quas totam
nostrum rerum est autem sunt rem eveniet architecto",
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"userId": 1,
}
`;
exports[`3) POST dev/test-basic-openapi/getPost 1`] = `
{
"body": "quia et suscipit
suscipit recusandae consequuntur expedita et cum
reprehenderit molestiae ut ut quas totam
nostrum rerum est autem sunt rem eveniet architecto",
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"userId": 1,
}
`;
exports[`4) POST dev/test-basic-openapi@latest/getPost 1`] = `
{
"body": "quia et suscipit
suscipit recusandae consequuntur expedita et cum
reprehenderit molestiae ut ut quas totam
nostrum rerum est autem sunt rem eveniet architecto",
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"userId": 1,
}
`;

Wyświetl plik

@ -22,7 +22,7 @@ for (const [i, fixture] of fixtures.entries()) {
body: expectedBody
} = fixture.response ?? {}
test(
test.sequential(
`${i}) ${method} ${fixture.path}`,
{
timeout: fixture.timeout ?? 60_000
@ -45,7 +45,12 @@ for (const [i, fixture] of fixtures.entries()) {
let body: any
if (type.includes('json')) {
body = await res.json()
try {
body = await res.json()
} catch (err) {
console.error('json error', err)
throw err
}
} else if (type.includes('text')) {
body = await res.text()
} else {
@ -59,6 +64,12 @@ for (const [i, fixture] of fixtures.entries()) {
if (snapshot) {
expect(body).toMatchSnapshot()
}
console.log(`${i}) ${method} ${fixture.path}`, {
status,
body,
headers: Object.fromEntries(res.headers.entries())
})
}
)
}

Wyświetl plik

@ -36,26 +36,10 @@ export const fixtures: E2ETestFixture[] = [
}
},
{
path: 'dev/test-basic-openapi@8d1a4900/getPost?postId=1'
path: 'dev/test-basic-openapi@b6e21206/getPost?postId=1'
},
{
path: 'test-basic-openapi/getPost',
request: {
searchParams: {
postId: 1
}
}
},
{
path: 'test-basic-openapi@8d1a4900/getPost',
request: {
searchParams: {
postId: 1
}
}
},
{
path: 'dev/test-basic-openapi@8d1a4900/getPost',
path: 'dev/test-basic-openapi@b6e21206/getPost',
request: {
searchParams: {
postId: 1
@ -70,5 +54,14 @@ export const fixtures: E2ETestFixture[] = [
postId: 1
}
}
},
{
path: 'dev/test-basic-openapi@latest/getPost',
request: {
method: 'POST',
json: {
postId: 1
}
}
}
]

Wyświetl plik

@ -22,18 +22,19 @@
"dev": "wrangler dev",
"start": "wrangler dev",
"cf-typegen": "wrangler types ./src/worker.d.ts",
"cf-clear-cache": "del .wrangler",
"clean": "del dist",
"test": "run-s test:*",
"test:lint": "eslint .",
"test:typecheck": "tsc --noEmit"
},
"dependencies": {
"@agentic/json-schema": "workspace:*",
"@agentic/platform": "workspace:*",
"@agentic/platform-api-client": "workspace:*",
"@agentic/platform-core": "workspace:*",
"@agentic/platform-types": "workspace:*",
"@agentic/platform-validators": "workspace:*",
"@cfworker/json-schema": "^4.1.1",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "catalog:",
"eventid": "catalog:",

Wyświetl plik

@ -18,11 +18,13 @@ export function cfValidateJsonSchemaObject<
>({
schema,
data,
errorMessage
errorMessage,
coerce = true
}: {
schema: any
data: Record<string, unknown>
errorMessage?: string
coerce?: boolean
}): T {
// Special-case check for required fields to give better error messages
if (Array.isArray(schema.required)) {
@ -38,8 +40,7 @@ export function cfValidateJsonSchemaObject<
}
}
const validator = new Validator(schema)
const validator = new Validator({ schema, coerce })
const result = validator.validate(data)
if (result.valid) {
return data as T

Wyświetl plik

@ -10,6 +10,7 @@ export async function fetchCache(
fetchResponse: () => Promise<Response>
}
): Promise<Response> {
console.log('cacheKey', cacheKey?.url)
let response: Response | undefined
if (cacheKey) {
@ -23,11 +24,11 @@ export async function fetchCache(
if (cacheKey) {
if (response.headers.has('Cache-Control')) {
// Note that cloudflare's `cache` should respect response headers.
ctx.waitUntil(
ctx.cache.put(cacheKey, response.clone()).catch((err) => {
console.warn('cache put error', cacheKey, err)
})
)
// ctx.waitUntil(
// ctx.cache.put(cacheKey, response.clone()).catch((err) => {
// console.warn('cache put error', cacheKey, err)
// })
// )
}
response.headers.set('cf-cache-status', 'MISS')

Wyświetl plik

@ -1,6 +1,6 @@
import type { AdminDeployment } from '@agentic/platform-types'
import { assert } from '@agentic/platform-core'
import { parseFaasIdentifier } from '@agentic/platform-validators'
import { parseToolIdentifier } from '@agentic/platform-validators'
import type { Context } from './types'
@ -8,7 +8,7 @@ export async function getAdminDeployment(
ctx: Context,
identifier: string
): Promise<{ deployment: AdminDeployment; toolPath: string }> {
const parsedFaas = parseFaasIdentifier(identifier)
const parsedFaas = parseToolIdentifier(identifier)
assert(parsedFaas, 404, `Invalid deployment identifier "${identifier}"`)
const deployment = await ctx.client.adminGetDeploymentByIdentifier({

Wyświetl plik

@ -90,14 +90,21 @@ export default {
assert(originResponse, 500, 'Origin response is required')
const res = new Response(originResponse.body, originResponse)
// Record the time it took for both the origin and gateway proxy to respond
// Record the time it took for both the origin and gateway to respond
recordTimespans()
res.headers.set('x-response-time', `${originTimespan!}ms`)
res.headers.set('x-proxy-response-time', `${gatewayTimespan!}ms`)
res.headers.set('x-origin-response-time', `${originTimespan!}ms`)
res.headers.set('x-response-time', `${gatewayTimespan!}ms`)
// Reset server to agentic because Cloudflare likes to override things
res.headers.set('server', 'agentic')
res.headers.delete('x-powered-by')
res.headers.delete('via')
res.headers.delete('nel')
res.headers.delete('report-to')
res.headers.delete('server-timing')
res.headers.delete('reporting-endpoints')
// const id: DurableObjectId = env.DO_RATE_LIMITER.idFromName('foo')
// const stub = env.DO_RATE_LIMITER.get(id)
// const greeting = await stub.sayHello('world')

Wyświetl plik

@ -7,7 +7,8 @@ export default [
ignores: [
'**/out/**',
'packages/types/src/openapi.d.ts',
'apps/gateway/src/worker.d.ts'
'apps/gateway/src/worker.d.ts',
'packages/json-schema/test/json-schema-test-suite.ts'
]
},
{

Wyświetl plik

@ -49,7 +49,9 @@ async function main() {
log: (...args: any[]) => {
if (program.opts().json) {
console.log(
args.length === 1 ? JSON.stringify(args[0]) : JSON.stringify(args)
args.length === 1
? JSON.stringify(args[0], null, 2)
: JSON.stringify(args, null, 2)
)
} else {
console.log(...args)

Wyświetl plik

@ -1,5 +1,5 @@
import type { Deployment } from '@agentic/platform-types'
import { parseFaasIdentifier } from '@agentic/platform-validators'
import { parseToolIdentifier } from '@agentic/platform-validators'
import { Command } from 'commander'
import { oraPromise } from 'ora'
@ -24,11 +24,7 @@ export function registerListDeploymentsCommand({
let label = 'Fetching all projects and deployments'
if (projectIdentifier) {
const auth = AuthStore.getAuth()
const parsedFaas = parseFaasIdentifier(projectIdentifier, {
// TODO: use team slug if available
namespace: auth.user.username
})
const parsedFaas = parseToolIdentifier(projectIdentifier)
if (!parsedFaas) {
throw new Error(`Invalid project identifier "${projectIdentifier}"`)

Wyświetl plik

@ -21,7 +21,7 @@ export function registerPublishCommand({ client, program, logger }: Context) {
AuthStore.requireAuth()
if (deploymentIdentifier) {
// TODO: parseFaasIdentifier
// TODO: parseToolIdentifier
}
const deployment = await oraPromise(

Wyświetl plik

@ -25,7 +25,7 @@ export async function resolveDeployment({
const namespace = auth.user.username
// TODO: resolve deploymentIdentifier; config name may include namespace?
// TODO: rename parseFaasIdentifier; movingn away from FaaS terminology
// TODO: this needs work...
deploymentIdentifier = `${namespace}/${config.name}@${fuzzyDeploymentIdentifierVersion}`
}

Wyświetl plik

@ -72,7 +72,7 @@
{
"required": true,
"schema": {
"type": "string"
"type": "integer"
},
"name": "postId",
"in": "path"

Wyświetl plik

@ -0,0 +1 @@
test/json-schema-test-suite.ts

Wyświetl plik

@ -0,0 +1,44 @@
{
"name": "@agentic/json-schema",
"version": "0.0.1",
"description": "A JSON schema validator that will run on Cloudflare workers. Supports drafts 4, 7, 2019-09, and 2020-12.",
"keywords": [
"json-schema",
"jsonschema",
"json",
"schema",
"cloudflare",
"worker",
"workers",
"service-worker"
],
"authors": [
"Jeremy Danyow <jdanyow@gmail.com>",
"Travis Fischer <travis@transitivebullsh.it>"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/transitive-bullshit/agentic-platform.git",
"directory": "packages/json-schema"
},
"type": "module",
"source": "./src/index.ts",
"types": "./src/index.ts",
"sideEffects": false,
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "run-s test:*",
"test:lint": "eslint .",
"test:typecheck": "tsc --noEmit",
"test:unit": "vitest run"
},
"devDependencies": {
"json-schema-test-suite": "git+https://github.com/json-schema-org/JSON-Schema-Test-Suite#76b529f"
},
"publishConfig": {
"access": "public"
}
}

Wyświetl plik

@ -0,0 +1,78 @@
> [!NOTE]
> This package is a fork of [@cfworker/json-schema](https://github.com/cfworker/cfworker) which adds [ajv-style coercion](https://ajv.js.org/coercion.html). Coercion can be enabled with a boolean flag.
# @agentic/json-schema
![](https://badgen.net/bundlephobia/minzip/@cfworker/json-schema)
![](https://badgen.net/bundlephobia/min/@cfworker/json-schema)
![](https://badgen.net/bundlephobia/dependency-count/@cfworker/json-schema)
![](https://badgen.net/bundlephobia/tree-shaking/@cfworker/json-schema)
![](https://badgen.net/npm/types/@cfworker/json-schema?icon=typescript)
A JSON schema validator that will run on Cloudflare workers. Supports drafts 4, 7, 2019-09, and 2020-12.
This library is validated against the [json-schema-test-suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite), a series of approximately 4,500 assertions maintained along with the json-schema specification. A small set of test cases are intentionally not supported due to performance constraints or lack of feature use. These list of unsupported features are maintained in [test/unsupported.ts](./test/unsupported.ts). While this library is not the fastest due to lack of code generation, it's consistently among the [most spec compliant](https://json-schema.org/implementations.html#benchmarks).
## Background
_Why another JSON schema validator?_
Cloudflare workers do not have APIs required by [Ajv](https://ajv.js.org/) schema compilation (`eval` or `new Function(code)`).
If possible use Ajv in a build step to precompile your schema. Otherwise this library could work for you.
## Basic usage
```js
import { Validator } from '@cfworker/json-schema'
const validator = new Validator({ type: 'number' })
const result = validator.validate(7)
```
## Specify meta schema draft
```js
const validator = new Validator({ type: 'number' }, '4') // draft-4
```
## Add schemas
```js
const validator = new Validator({
$id: 'https://foo.bar/baz',
$ref: '/beep'
})
validator.addSchema({ $id: 'https://foo.bar/beep', type: 'boolean' })
```
## Include all errors
By default the validator stops processing after the first error. Set the `shortCircuit` parameter to `false` to emit all errors.
```js
const shortCircuit = false;
const draft = '2019-09';
const schema = {
type: 'object',
required: ['name', 'email', 'number', 'bool'],
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' },
number: { type: 'number' },
bool: { type: 'boolean' }
}
};
const validator = new Validator(schema, draft, shortCircuit);
const result = validator.validate({
name: 'hello',
email: 5, // invalid type
number: 'Hello' // invalid type
bool: 'false' // invalid type
});
```

Wyświetl plik

@ -0,0 +1,195 @@
import type { InstanceType } from './types'
export function getInstanceType(
instance: any
): Exclude<InstanceType, 'integer'> {
const rawInstanceType = typeof instance
switch (rawInstanceType) {
case 'boolean':
case 'number':
case 'string':
return rawInstanceType
case 'object':
if (instance === null) {
return 'null'
} else if (Array.isArray(instance)) {
return 'array'
} else {
return 'object'
}
default:
// undefined, bigint, function, symbol
throw new Error(
`Instances of "${rawInstanceType}" type are not supported.`
)
}
}
export function coerceValue({
instanceType,
instance,
$type,
recur = true
}: {
instanceType: Exclude<InstanceType, 'integer'>
instance: any
$type: InstanceType
recur?: boolean
}): any | undefined {
if ($type === undefined) {
return instance
}
if (Number.isNaN(instance)) {
return undefined
}
let valid = true
if ($type === 'integer') {
if (instanceType !== 'number' || instance % 1 || Number.isNaN(instance)) {
valid = false
}
} else if (instanceType !== $type) {
valid = false
}
if (valid) {
return instance
}
if (!recur) {
return
}
switch ($type) {
case 'number':
switch (instanceType) {
case 'string':
instance = +instance
break
case 'boolean':
instance = instance === true ? 1 : 0
break
case 'null':
instance = 0
break
case 'array':
if (instance.length === 1) {
instance = instance[0]
}
break
}
break
case 'string':
switch (instanceType) {
case 'boolean':
instance = instance === true ? 'true' : 'false'
break
case 'number':
instance = String(instance)
break
case 'null':
instance = ''
break
case 'array':
if (instance.length === 1) {
instance = instance[0]
}
break
}
break
case 'boolean':
switch (instanceType) {
case 'string':
if (instance === 'true') {
instance = true
} else if (instance === 'false') {
instance = false
}
break
case 'number':
if (instance === 1) {
instance = true
} else if (instance === 0) {
instance = false
}
break
case 'null':
instance = false
break
case 'array':
if (instance.length === 1) {
instance = instance[0]
}
break
}
break
case 'null':
switch (instanceType) {
case 'string':
if (instance === '') {
instance = null
}
break
case 'number':
if (instance === 0) {
instance = null
}
break
case 'boolean':
if (instance === false) {
instance = null
}
break
case 'array':
if (instance.length === 1 && instance[0] === null) {
instance = null
}
break
}
break
case 'array':
switch (instanceType) {
case 'string':
instance = [instance]
break
case 'number':
instance = [instance]
break
case 'boolean':
instance = [instance]
break
case 'null':
instance = [null]
break
}
break
}
return coerceValue({
instanceType: getInstanceType(instance),
instance,
$type,
recur: false
})
}

Wyświetl plik

@ -0,0 +1,39 @@
export function deepCompareStrict(a: any, b: any): boolean {
const typeofa = typeof a
if (typeofa !== typeof b) {
return false
}
if (Array.isArray(a)) {
if (!Array.isArray(b)) {
return false
}
const length = a.length
if (length !== b.length) {
return false
}
for (let i = 0; i < length; i++) {
if (!deepCompareStrict(a[i], b[i])) {
return false
}
}
return true
}
if (typeofa === 'object') {
if (!a || !b) {
return a === b
}
const aKeys = Object.keys(a)
const bKeys = Object.keys(b)
const length = aKeys.length
if (length !== bKeys.length) {
return false
}
for (const k of aKeys) {
if (!deepCompareStrict(a[k], b[k])) {
return false
}
}
return true
}
return a === b
}

Wyświetl plik

@ -0,0 +1,189 @@
/* eslint-disable unicorn/no-thenable */
import type { Schema } from './types'
import { encodePointer } from './pointer'
export const schemaKeyword: Record<string, boolean> = {
additionalItems: true,
unevaluatedItems: true,
items: true,
contains: true,
additionalProperties: true,
unevaluatedProperties: true,
propertyNames: true,
not: true,
if: true,
then: true,
else: true
}
export const schemaArrayKeyword: Record<string, boolean> = {
prefixItems: true,
items: true,
allOf: true,
anyOf: true,
oneOf: true
}
export const schemaMapKeyword: Record<string, boolean> = {
$defs: true,
definitions: true,
properties: true,
patternProperties: true,
dependentSchemas: true
}
export const ignoredKeyword: Record<string, boolean> = {
id: true,
$id: true,
$ref: true,
$schema: true,
$anchor: true,
$vocabulary: true,
$comment: true,
default: true,
enum: true,
const: true,
required: true,
type: true,
maximum: true,
minimum: true,
exclusiveMaximum: true,
exclusiveMinimum: true,
multipleOf: true,
maxLength: true,
minLength: true,
pattern: true,
format: true,
maxItems: true,
minItems: true,
uniqueItems: true,
maxProperties: true,
minProperties: true
}
/**
* Default base URI for schemas without an $id.
* https://json-schema.org/draft/2019-09/json-schema-core.html#initial-base
* https://tools.ietf.org/html/rfc3986#section-5.1
*/
export const initialBaseURI: URL =
globalThis.self !== undefined &&
self.location &&
self.location.origin !== 'null'
? new URL(self.location.origin + self.location.pathname + location.search)
: new URL('https://github.com/cfworker')
export function dereference(
schema: Schema | boolean,
lookup: Record<string, Schema | boolean> = Object.create(null),
baseURI: URL = initialBaseURI,
basePointer = ''
): Record<string, Schema | boolean> {
if (schema && typeof schema === 'object' && !Array.isArray(schema)) {
const id: string = schema.$id || schema.id
if (id) {
const url = new URL(id, baseURI.href)
if (url.hash.length > 1) {
lookup[url.href] = schema
} else {
url.hash = '' // normalize hash https://url.spec.whatwg.org/#dom-url-hash
if (basePointer === '') {
baseURI = url
} else {
dereference(schema, lookup, baseURI)
}
}
}
} else if (schema !== true && schema !== false) {
return lookup
}
// compute the schema's URI and add it to the mapping.
const schemaURI = baseURI.href + (basePointer ? '#' + basePointer : '')
if (lookup[schemaURI] !== undefined) {
throw new Error(`Duplicate schema URI "${schemaURI}".`)
}
lookup[schemaURI] = schema
// exit early if this is a boolean schema.
if (schema === true || schema === false) {
return lookup
}
// set the schema's absolute URI.
if (schema.__absolute_uri__ === undefined) {
Object.defineProperty(schema, '__absolute_uri__', {
enumerable: false,
value: schemaURI
})
}
// if a $ref is found, resolve it's absolute URI.
if (schema.$ref && schema.__absolute_ref__ === undefined) {
const url = new URL(schema.$ref, baseURI.href)
// eslint-disable-next-line no-self-assign
url.hash = url.hash // normalize hash https://url.spec.whatwg.org/#dom-url-hash
Object.defineProperty(schema, '__absolute_ref__', {
enumerable: false,
value: url.href
})
}
// if a $recursiveRef is found, resolve it's absolute URI.
if (schema.$recursiveRef && schema.__absolute_recursive_ref__ === undefined) {
const url = new URL(schema.$recursiveRef, baseURI.href)
// eslint-disable-next-line no-self-assign
url.hash = url.hash // normalize hash https://url.spec.whatwg.org/#dom-url-hash
Object.defineProperty(schema, '__absolute_recursive_ref__', {
enumerable: false,
value: url.href
})
}
// if an $anchor is found, compute it's URI and add it to the mapping.
if (schema.$anchor) {
const url = new URL('#' + schema.$anchor, baseURI.href)
lookup[url.href] = schema
}
// process subschemas.
for (const key in schema) {
if (ignoredKeyword[key]) {
continue
}
const keyBase = `${basePointer}/${encodePointer(key)}`
const subSchema = schema[key]
if (Array.isArray(subSchema)) {
if (schemaArrayKeyword[key]) {
const length = subSchema.length
for (let i = 0; i < length; i++) {
dereference(subSchema[i], lookup, baseURI, `${keyBase}/${i}`)
}
}
} else if (schemaMapKeyword[key]) {
for (const subKey in subSchema) {
dereference(
subSchema[subKey],
lookup,
baseURI,
`${keyBase}/${encodePointer(subKey)}`
)
}
} else {
dereference(subSchema, lookup, baseURI, keyBase)
}
}
return lookup
}
// schema identification examples
// https://json-schema.org/draft/2019-09/json-schema-core.html#rfc.appendix.A
// $ref delegation
// https://github.com/json-schema-org/json-schema-spec/issues/514
// output format
// https://json-schema.org/draft/2019-09/json-schema-core.html#output
// JSON pointer
// https://tools.ietf.org/html/rfc6901
// JSON relative pointer
// https://tools.ietf.org/html/draft-handrews-relative-json-pointer-01

Wyświetl plik

@ -0,0 +1,168 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable no-control-regex */
/* eslint-disable security/detect-unsafe-regex */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
// based on https://github.com/epoberezkin/ajv/blob/master/lib/compile/formats.js
const DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/
const DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
const TIME = /^(\d\d):(\d\d):(\d\d)(\.\d+)?(z|[+-]\d\d(?::?\d\d)?)?$/i
const HOSTNAME =
/^(?=.{1,253}\.?$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[-0-9a-z]{0,61}[0-9a-z])?)*\.?$/i
// const URI = /^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i;
const URIREF =
/^(?:[a-z][a-z0-9+\-.]*:)?(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'"()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*)?(?:\?(?:[a-z0-9\-._~!$&'"()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'"()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i
// uri-template: https://tools.ietf.org/html/rfc6570
const URITEMPLATE =
/^(?:(?:[^\u0000-\u0020"'<>%\\^`{|}]|%[0-9a-f]{2})|\{[+#./;?&=,!@|]?(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?(?:,(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?)*\})*$/i
// For the source: https://gist.github.com/dperini/729294
// For test cases: https://mathiasbynens.be/demo/url-regex
const URL_ =
/^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!10(?:\.\d{1,3}){3})(?!127(?:\.\d{1,3}){3})(?!169\.254(?:\.\d{1,3}){2})(?!192\.168(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u{00A1}-\u{FFFF}0-9]+-?)*[a-z\u{00A1}-\u{FFFF}0-9]+)(?:\.(?:[a-z\u{00A1}-\u{FFFF}0-9]+-?)*[a-z\u{00A1}-\u{FFFF}0-9]+)*(?:\.(?:[a-z\u{00A1}-\u{FFFF}]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$/iu
const UUID = /^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i
const JSON_POINTER = /^(?:\/(?:[^~/]|~0|~1)*)*$/
const JSON_POINTER_URI_FRAGMENT =
/^#(?:\/(?:[a-z0-9_\-.!$&'()*+,;:=@]|%[0-9a-f]{2}|~0|~1)*)*$/i
const RELATIVE_JSON_POINTER = /^(?:0|[1-9][0-9]*)(?:#|(?:\/(?:[^~/]|~0|~1)*)*)$/
// // date: http://tools.ietf.org/html/rfc3339#section-5.6
// const FASTDATE = /^\d\d\d\d-[0-1]\d-[0-3]\d$/;
// // date-time: http://tools.ietf.org/html/rfc3339#section-5.6
// const FASTTIME =
// /^(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)?$/i;
// const FASTDATETIME =
// /^\d\d\d\d-[0-1]\d-[0-3]\d[t\s](?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)$/i;
// // uri: https://github.com/mafintosh/is-my-json-valid/blob/master/formats.js
// // const FASTURI = /^(?:[a-z][a-z0-9+-.]*:)(?:\/?\/)?[^\s]*$/i;
// const FASTURIREFERENCE =
// /^(?:(?:[a-z][a-z0-9+-.]*:)?\/?\/)?(?:[^\\\s#][^\s#]*)?(?:#[^\\\s]*)?$/i;
// https://github.com/ExodusMovement/schemasafe/blob/master/src/formats.js
const EMAIL = (input: string) => {
if (input[0] === '"') return false
const [name, host, ...rest] = input.split('@')
if (
!name ||
!host ||
rest.length !== 0 ||
name.length > 64 ||
host.length > 253
)
return false
if (name[0] === '.' || name.endsWith('.') || name.includes('..')) return false
if (
!/^[a-z0-9.-]+$/i.test(host) ||
!/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+$/i.test(name)
)
return false
return host
.split('.')
.every((part) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(part))
}
// optimized https://www.safaribooksonline.com/library/view/regular-expressions-cookbook/9780596802837/ch07s16.html
const IPV4 =
/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/
// optimized http://stackoverflow.com/questions/53497/regular-expression-that-matches-valid-ipv6-addresses
const IPV6 =
/^((([0-9a-f]{1,4}:){7}([0-9a-f]{1,4}|:))|(([0-9a-f]{1,4}:){6}(:[0-9a-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){5}(((:[0-9a-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){4}(((:[0-9a-f]{1,4}){1,3})|((:[0-9a-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){3}(((:[0-9a-f]{1,4}){1,4})|((:[0-9a-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){2}(((:[0-9a-f]{1,4}){1,5})|((:[0-9a-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){1}(((:[0-9a-f]{1,4}){1,6})|((:[0-9a-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-f]{1,4}){1,7})|((:[0-9a-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))$/i
// https://github.com/ExodusMovement/schemasafe/blob/master/src/formats.js
const DURATION = (input: string) =>
input.length > 1 &&
input.length < 80 &&
(/^P\d+([.,]\d+)?W$/.test(input) ||
(/^P[\dYMDTHS]*(\d[.,]\d+)?[YMDHS]$/.test(input) &&
/^P([.,\d]+Y)?([.,\d]+M)?([.,\d]+D)?(T([.,\d]+H)?([.,\d]+M)?([.,\d]+S)?)?$/.test(
input
)))
function bind(r: RegExp) {
return r.test.bind(r)
}
export const format: Record<string, (s: string) => boolean> = {
date,
time: time.bind(undefined, false),
'date-time': date_time,
duration: DURATION,
uri,
'uri-reference': bind(URIREF),
'uri-template': bind(URITEMPLATE),
url: bind(URL_),
email: EMAIL,
hostname: bind(HOSTNAME),
ipv4: bind(IPV4),
ipv6: bind(IPV6),
regex,
uuid: bind(UUID),
'json-pointer': bind(JSON_POINTER),
'json-pointer-uri-fragment': bind(JSON_POINTER_URI_FRAGMENT),
'relative-json-pointer': bind(RELATIVE_JSON_POINTER)
}
function isLeapYear(year: number) {
// https://tools.ietf.org/html/rfc3339#appendix-C
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
}
function date(str: string) {
// full-date from http://tools.ietf.org/html/rfc3339#section-5.6
const matches = str.match(DATE)
if (!matches) return false
const year = +matches[1]
const month = +matches[2]
const day = +matches[3]
return (
month >= 1 &&
month <= 12 &&
day >= 1 &&
day <= (month === 2 && isLeapYear(year) ? 29 : DAYS[month])
)
}
function time(full: boolean, str: string) {
const matches = str.match(TIME)
if (!matches) return false
const hour = +matches[1]
const minute = +matches[2]
const second = +matches[3]
const timeZone = !!matches[5]
return (
((hour <= 23 && minute <= 59 && second <= 59) ||
(hour === 23 && minute === 59 && second === 60)) &&
(!full || timeZone)
)
}
const DATE_TIME_SEPARATOR = /t|\s/i
function date_time(str: string) {
// http://tools.ietf.org/html/rfc3339#section-5.6
const dateTime = str.split(DATE_TIME_SEPARATOR)
return dateTime.length === 2 && date(dateTime[0]) && time(true, dateTime[1])
}
const NOT_URI_FRAGMENT = /\/|:/
const URI_PATTERN =
/^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i
function uri(str: string): boolean {
// http://jmrware.com/articles/2009/uri_regexp/URI_regex.html + optional protocol + required "."
return NOT_URI_FRAGMENT.test(str) && URI_PATTERN.test(str)
}
const Z_ANCHOR = /[^\\]\\Z/
function regex(str: string) {
if (Z_ANCHOR.test(str)) return false
try {
// eslint-disable-next-line security/detect-non-literal-regexp
new RegExp(str, 'u')
return true
} catch {
return false
}
}

Wyświetl plik

@ -0,0 +1,9 @@
export * from './coercion'
export * from './deep-compare-strict'
export * from './dereference'
export * from './format'
export * from './pointer'
export * from './types'
export * from './ucs2-length'
export * from './validate'
export * from './validator'

Wyświetl plik

@ -0,0 +1,7 @@
export function encodePointer(p: string): string {
return encodeURI(escapePointer(p))
}
export function escapePointer(p: string): string {
return p.replaceAll('~', '~0').replaceAll('/', '~1')
}

Wyświetl plik

@ -0,0 +1,93 @@
export type SchemaDraft = '4' | '7' | '2019-09' | '2020-12'
export const enum OutputFormat {
Flag = 1,
Basic = 2,
Detailed = 4
}
export type InstanceType =
| 'array'
| 'boolean'
| 'integer'
| 'null'
| 'number'
| 'object'
| 'string'
export interface Schema {
$id?: string
$anchor?: string
$recursiveAnchor?: boolean
$ref?: string
$recursiveRef?: '#'
$schema?: string
$comment?: string
$defs?: any
$vocabulary?: Record<string, boolean>
type?: InstanceType | InstanceType[]
const?: any
enum?: any[]
required?: string[]
not?: Schema
anyOf?: Schema[]
allOf?: Schema[]
oneOf?: Schema[]
if?: Schema
then?: Schema
else?: Schema
format?: string
properties?: Record<string | number, Schema | boolean>
patternProperties?: Record<string, Schema | boolean>
additionalProperties?: Schema | boolean
unevaluatedProperties?: Schema | boolean
minProperties?: number
maxProperties?: number
propertyNames?: Schema
dependentRequired?: Record<string, string[]>
dependentSchemas?: Record<string, Schema>
dependencies?: Record<string, Schema | string[]>
prefixItems?: Array<Schema | boolean>
items?: Schema | boolean | Array<Schema | boolean>
additionalItems?: Schema | boolean
unevaluatedItems?: Schema | boolean
contains?: Schema | boolean
minContains?: number
maxContains?: number
minItems?: number
maxItems?: number
uniqueItems?: boolean
minimum?: number
maximum?: number
exclusiveMinimum?: number | boolean
exclusiveMaximum?: number | boolean
multipleOf?: number
minLength?: number
maxLength?: number
pattern?: string
__absolute_ref__?: string
__absolute_recursive_ref__?: string
__absolute_uri__?: string
[key: string]: any
}
export interface OutputUnit {
keyword: string
keywordLocation: string
instanceLocation: string
error: string
}
export type ValidationResult = {
valid: boolean
errors: OutputUnit[]
instance: any
}

Wyświetl plik

@ -0,0 +1,27 @@
/* eslint-disable eqeqeq */
/* eslint-disable unicorn/prefer-code-point */
/**
* Get UCS-2 length of a string
* https://mathiasbynens.be/notes/javascript-encoding
* https://github.com/bestiejs/punycode.js - punycode.ucs2.decode
*/
export function ucs2length(s: string): number {
const length = s.length
let result = 0
let index = 0
let charCode: number
while (index < length) {
result++
charCode = s.charCodeAt(index++)
if (charCode >= 0xd8_00 && charCode <= 0xdb_ff && index < length) {
// high surrogate, and there is a next character
charCode = s.charCodeAt(index)
if ((charCode & 0xfc_00) == 0xdc_00) {
// low surrogate
index++
}
}
}
return result
}

Plik diff jest za duży Load Diff

Wyświetl plik

@ -0,0 +1,47 @@
import type { Schema, SchemaDraft, ValidationResult } from './types'
import { dereference } from './dereference'
import { validate } from './validate'
export class Validator {
private readonly lookup: ReturnType<typeof dereference>
private readonly schema: Schema | boolean
private readonly draft: SchemaDraft
private readonly shortCircuit
private readonly coerce
constructor({
schema,
draft = '2019-09',
shortCircuit = true,
coerce = false
}: {
schema: Schema | boolean
draft?: SchemaDraft
shortCircuit?: boolean
coerce?: boolean
}) {
this.schema = schema
this.draft = draft
this.shortCircuit = shortCircuit
this.coerce = coerce
this.lookup = dereference(schema)
}
public validate(instance: any): ValidationResult {
return validate(
instance,
this.schema,
this.draft,
this.lookup,
this.shortCircuit,
this.coerce
)
}
public addSchema(schema: Schema, id?: string): void {
if (id) {
schema = { ...schema, $id: id }
}
dereference(schema, this.lookup)
}
}

Wyświetl plik

@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import { validate } from '../src/index'
describe('json-schema coercion', () => {
it('string => number coercion', () => {
const result = validate('7', { type: 'number' }, '2019-09', undefined, true)
expect(result.valid).to.equal(true)
expect(result.instance).to.equal(7)
})
it('boolean => number coercion', () => {
const result = validate(
true,
{ type: 'number' },
'2019-09',
undefined,
true
)
expect(result.valid).to.equal(true)
expect(result.instance).to.equal(1)
})
it('null => number coercion', () => {
const result = validate(
null,
{ type: 'number' },
'2019-09',
undefined,
true
)
expect(result.valid).to.equal(true)
expect(result.instance).to.equal(0)
})
it('array => number coercion', () => {
const result = validate([1], { type: 'number' }, '2019-09', undefined, true)
expect(result.valid).to.equal(true)
expect(result.instance).to.equal(1)
})
it('boolean => string coercion', () => {
const result = validate(
true,
{ type: 'string' },
'2019-09',
undefined,
true
)
expect(result.valid).to.equal(true)
expect(result.instance).to.equal('true')
})
it('number => string coercion', () => {
const result = validate(
72.3,
{ type: 'string' },
'2019-09',
undefined,
true
)
expect(result.valid).to.equal(true)
expect(result.instance).to.equal('72.3')
})
it('null => string coercion', () => {
const result = validate(
null,
{ type: 'string' },
'2019-09',
undefined,
true
)
expect(result.valid).to.equal(true)
expect(result.instance).to.equal('')
})
it('array => string coercion', () => {
const result = validate(
['nala'],
{ type: 'string' },
'2019-09',
undefined,
true
)
expect(result.valid).to.equal(true)
expect(result.instance).to.equal('nala')
})
it('string => boolean coercion', () => {
const result = validate(
'true',
{ type: 'boolean' },
'2019-09',
undefined,
true
)
expect(result.valid).to.equal(true)
expect(result.instance).to.equal(true)
})
it('string => null coercion', () => {
const result = validate('', { type: 'null' }, '2019-09', undefined, true)
expect(result.valid).to.equal(true)
expect(result.instance).to.equal(null)
})
})

Wyświetl plik

@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { dereference, validate, type ValidationResult } from '../src/index.js'
import { remotes, suites } from './json-schema-test-suite.js'
import { loadMeta } from './meta-schema.js'
import { unsupportedTests } from './unsupported.js'
const remotesLookup = Object.create(null)
for (const { name, schema } of remotes) {
dereference(schema, remotesLookup, new URL(name))
}
Object.freeze(remotesLookup)
;(globalThis as any).location = {
origin: 'https://example.com',
host: 'example.com',
hostname: 'example.com',
pathname: '/',
search: '',
hash: ''
}
describe('json-schema', () => {
const failures: Record<string, Record<string, Record<string, true>>> = {}
for (const { draft, name, tests: tests0 } of suites) {
if (name.endsWith('/unknownKeyword')) {
continue
}
describe(name, () => {
for (const { schema, description: description1, tests } of tests0) {
const schemaLookup = dereference(schema)
const supportedTests = tests.filter((test) => {
return !unsupportedTests[name]?.[description1]?.[test.description]
})
if (!supportedTests.length) {
continue
}
describe(description1, () => {
for (const {
data,
valid,
description: description2,
debug
} of tests) {
if (unsupportedTests[name]?.[description1]?.[description2]) {
continue
}
;(debug ? it.only : it)(description2, async () => {
if (debug) {
// eslint-disable-next-line no-debugger
debugger
}
const metaLookup = await loadMeta()
const lookup = {
...metaLookup,
...remotesLookup,
...schemaLookup
}
let result: ValidationResult | undefined
try {
result = validate(data, schema, draft, lookup)
} catch {}
if (result?.valid !== valid) {
failures[name] = failures[name] ?? {}
failures[name][description1] =
failures[name][description1] ?? {}
failures[name][description1][description2] = true
}
expect(result?.valid).to.equal(valid, description2)
})
}
})
}
})
}
// after(() => console.log(JSON.stringify(failures, null, 2)));
})

Wyświetl plik

@ -0,0 +1,41 @@
import { dereference, type Schema } from '../src/index'
let lookup: Record<string, Schema> | undefined
export async function loadMeta() {
if (lookup) {
return lookup
}
lookup = Object.create({})
const ids = [
'http://json-schema.org/draft-04/schema',
'http://json-schema.org/draft-07/schema',
'https://json-schema.org/draft/2019-09/schema',
'https://json-schema.org/draft/2019-09/meta/core',
'https://json-schema.org/draft/2019-09/meta/applicator',
'https://json-schema.org/draft/2019-09/meta/validation',
'https://json-schema.org/draft/2019-09/meta/meta-data',
'https://json-schema.org/draft/2019-09/meta/format',
'https://json-schema.org/draft/2019-09/meta/content',
'https://json-schema.org/draft/2020-12/schema',
'https://json-schema.org/draft/2020-12/meta/core',
'https://json-schema.org/draft/2020-12/meta/applicator',
'https://json-schema.org/draft/2020-12/meta/validation',
'https://json-schema.org/draft/2020-12/meta/meta-data',
'https://json-schema.org/draft/2020-12/meta/format-annotation',
'https://json-schema.org/draft/2020-12/meta/content',
'https://json-schema.org/draft/2020-12/meta/unevaluated'
]
await Promise.all(
ids.map(async (id) => {
const response = await fetch(id)
const schema = await response.json()
dereference(schema, lookup)
})
)
Object.freeze(lookup)
return lookup
}

Wyświetl plik

@ -0,0 +1,25 @@
import type { Schema, SchemaDraft } from '../src/types'
export interface SchemaTestSuite {
draft: SchemaDraft
name: string
tests: SchemaTest[]
}
export interface SchemaTest {
description: string
schema: any
tests: SchemaTestCase[]
}
export interface SchemaTestCase {
description: string
data: any
valid: boolean
debug?: true
}
export interface Remote {
name: string
schema: Schema
}

Wyświetl plik

@ -0,0 +1,593 @@
export const unsupportedTests: Record<
string,
Record<string, Record<string, boolean>>
> = {
'draft2019-09/format': {
'email format': {
'invalid email string is only an annotation by default': true
},
'regex format': {
'invalid regex string is only an annotation by default': true
},
'ipv4 format': {
'invalid ipv4 string is only an annotation by default': true
},
'ipv6 format': {
'invalid ipv6 string is only an annotation by default': true
},
'hostname format': {
'invalid hostname string is only an annotation by default': true
},
'date format': {
'invalid date string is only an annotation by default': true
},
'date-time format': {
'invalid date-time string is only an annotation by default': true
},
'time format': {
'invalid time string is only an annotation by default': true
},
'json-pointer format': {
'invalid json-pointer string is only an annotation by default': true
},
'relative-json-pointer format': {
'invalid relative-json-pointer string is only an annotation by default':
true
},
'uri format': {
'invalid uri string is only an annotation by default': true
},
'uri-reference format': {
'invalid uri-reference string is only an annotation by default': true
},
'uri-template format': {
'invalid uri-template string is only an annotation by default': true
},
'uuid format': {
'invalid uuid string is only an annotation by default': true
},
'duration format': {
'invalid duration string is only an annotation by default': true
}
},
'draft4/type': {
'multiple types can be specified in an array': {
'an integer is valid': true
}
},
'draft7/type': {
'multiple types can be specified in an array': {
'an integer is valid': true
},
'not multiple types': {
mismatch: true
}
},
'draft2019-09/type': {
'multiple types can be specified in an array': {
'an integer is valid': true
}
},
'draft2020-12/type': {
'multiple types can be specified in an array': {
'an integer is valid': true
}
},
'draft4/not': {
'not multiple types': {
mismatch: true
}
},
'draft7/not': {
'not multiple types': {
mismatch: true
}
},
'draft2019-09/not': {
'not multiple types': {
mismatch: true
}
},
'draft2020-12/not': {
'not multiple types': {
mismatch: true
}
},
'draft2019-09/optional/format/date': {
'validation of date strings': {
'a invalid date string with 32 days in January': true,
'a invalid date string with 29 days in February (normal)': true,
'a invalid date string with 30 days in February (leap)': true,
'a invalid date string with 32 days in March': true,
'a invalid date string with 31 days in April': true,
'a invalid date string with 32 days in May': true,
'a invalid date string with 31 days in June': true,
'a invalid date string with 32 days in July': true,
'a invalid date string with 32 days in August': true,
'a invalid date string with 31 days in September': true,
'a invalid date string with 32 days in October': true,
'a invalid date string with 31 days in November': true,
'a invalid date string with 32 days in December': true,
'a invalid date string with invalid month': true,
'invalid month': true,
'invalid month-day combination': true,
'2021 is not a leap year': true
}
},
'draft2019-09/optional/format/idn-email': {
'validation of an internationalized e-mail addresses': {
'an invalid idn e-mail address': true,
'an invalid e-mail address': true
}
},
'draft2019-09/optional/format/idn-hostname': {
'validation of internationalized host names': {
'illegal first char U+302E Hangul single dot tone mark': true,
'contains illegal char U+302E Hangul single dot tone mark': true,
'a host name with a component too long': true,
'invalid label, correct Punycode': true,
'invalid Punycode': true,
'U-label contains "--" in the 3rd and 4th position': true,
'U-label starts with a dash': true,
'U-label ends with a dash': true,
'U-label starts and ends with a dash': true,
'Begins with a Spacing Combining Mark': true,
'Begins with a Nonspacing Mark': true,
'Begins with an Enclosing Mark': true,
'Exceptions that are DISALLOWED, right-to-left chars': true,
'Exceptions that are DISALLOWED, left-to-right chars': true,
"MIDDLE DOT with no preceding 'l'": true,
'MIDDLE DOT with nothing preceding': true,
"MIDDLE DOT with no following 'l'": true,
'MIDDLE DOT with nothing following': true,
'Greek KERAIA not followed by Greek': true,
'Greek KERAIA not followed by anything': true,
'Hebrew GERESH not preceded by Hebrew': true,
'Hebrew GERESH not preceded by anything': true,
'Hebrew GERSHAYIM not preceded by Hebrew': true,
'Hebrew GERSHAYIM not preceded by anything': true,
'KATAKANA MIDDLE DOT with no Hiragana, Katakana, or Han': true,
'KATAKANA MIDDLE DOT with no other characters': true,
'Arabic-Indic digits mixed with Extended Arabic-Indic digits': true,
'ZERO WIDTH JOINER not preceded by Virama': true,
'ZERO WIDTH JOINER not preceded by anything': true
}
},
'draft2019-09/optional/format/ipv4': {
'validation of IP addresses': {
'leading zeroes should be rejected, as they are treated as octals': true
}
},
'draft2019-09/optional/format/iri-reference': {
'validation of IRI References': {
'an invalid IRI Reference': true,
'an invalid IRI fragment': true
}
},
'draft2019-09/optional/format/iri': {
'validation of IRIs': {
'an invalid IRI based on IPv6': true,
'an invalid relative IRI Reference': true,
'an invalid IRI': true,
'an invalid IRI though valid IRI reference': true
}
},
'draft2019-09/optional/format/time': {
'validation of time strings': {
'valid leap second, positive time-offset': true,
'valid leap second, large positive time-offset': true,
'invalid leap second, positive time-offset (wrong hour)': true,
'invalid leap second, positive time-offset (wrong minute)': true,
'valid leap second, negative time-offset': true,
'valid leap second, large negative time-offset': true,
'invalid leap second, negative time-offset (wrong hour)': true,
'invalid leap second, negative time-offset (wrong minute)': true,
'an invalid time string with invalid hour': true,
'an invalid time string with invalid time numoffset hour': true,
'an invalid time string with invalid time numoffset minute': true
}
},
'draft2019-09/optional/non-bmp-regex': {
'Proper UTF-16 surrogate pair handling: pattern': {
'matches empty': true,
'matches two': true
},
'Proper UTF-16 surrogate pair handling: patternProperties': {
"doesn't match two": true
}
},
'draft2019-09/optional/unicode': {
'unicode semantics should be used for all pattern matching': {
'literal unicode character in json string': true,
'unicode character in hex format in string': true
},
'unicode digits are more than 0 through 9': {
'non-ascii digits (BENGALI DIGIT FOUR, BENGALI DIGIT TWO)': true
},
'unicode semantics should be used for all patternProperties matching': {
'literal unicode character in json string': true,
'unicode character in hex format in string': true
}
},
'draft2020-12/defs': {
'validate definition against metaschema': {
'invalid definition schema': true
}
},
'draft2020-12/dynamicRef': {
'A $dynamicRef to a $dynamicAnchor in the same schema resource should behave like a normal $ref to an $anchor':
{
'An array containing non-strings is invalid': true
},
'A $dynamicRef to an $anchor in the same schema resource should behave like a normal $ref to an $anchor':
{
'An array containing non-strings is invalid': true
},
'A $ref to a $dynamicAnchor in the same schema resource should behave like a normal $ref to an $anchor':
{
'An array of strings is valid': true,
'An array containing non-strings is invalid': true
},
'A $dynamicRef should resolve to the first $dynamicAnchor still in scope that is encountered when the schema is evaluated':
{
'An array containing non-strings is invalid': true
},
"A $dynamicRef with intermediate scopes that don't include a matching $dynamicAnchor should not affect dynamic scope resolution":
{
'An array containing non-strings is invalid': true
},
'A $dynamicRef that initially resolves to a schema with a matching $dynamicAnchor should resolve to the first $dynamicAnchor in the dynamic scope':
{
'The recursive part is not valid against the root': true
},
'multiple dynamic paths to the $dynamicRef keyword': {
'recurse to integerNode - floats are not allowed': true
},
'after leaving a dynamic scope, it should not be used by a $dynamicRef': {
'string matches /$defs/thingy, but the $dynamicRef does not stop here':
true,
'first_scope is not in dynamic scope for the $dynamicRef': true
}
},
'draft2020-12/format': {
'email format': {
'invalid email string is only an annotation by default': true
},
'regex format': {
'invalid regex string is only an annotation by default': true
},
'ipv4 format': {
'invalid ipv4 string is only an annotation by default': true
},
'ipv6 format': {
'invalid ipv6 string is only an annotation by default': true
},
'hostname format': {
'invalid hostname string is only an annotation by default': true
},
'date format': {
'invalid date string is only an annotation by default': true
},
'date-time format': {
'invalid date-time string is only an annotation by default': true
},
'time format': {
'invalid time string is only an annotation by default': true
},
'json-pointer format': {
'invalid json-pointer string is only an annotation by default': true
},
'relative-json-pointer format': {
'invalid relative-json-pointer string is only an annotation by default':
true
},
'uri format': {
'invalid uri string is only an annotation by default': true
},
'uri-reference format': {
'invalid uri-reference string is only an annotation by default': true
},
'uri-template format': {
'invalid uri-template string is only an annotation by default': true
},
'uuid format': {
'invalid uuid string is only an annotation by default': true
},
'duration format': {
'invalid duration string is only an annotation by default': true
}
},
'draft2020-12/id': {
'Invalid use of fragments in location-independent $id': {
'Identifier name': true,
'Identifier name and no ref': true,
'Identifier path': true,
'Identifier name with absolute URI': true,
'Identifier path with absolute URI': true,
'Identifier name with base URI change in subschema': true,
'Identifier path with base URI change in subschema': true
}
},
'draft2020-12/optional/format/date': {
'validation of date strings': {
'a invalid date string with 32 days in January': true,
'a invalid date string with 29 days in February (normal)': true,
'a invalid date string with 30 days in February (leap)': true,
'a invalid date string with 32 days in March': true,
'a invalid date string with 31 days in April': true,
'a invalid date string with 32 days in May': true,
'a invalid date string with 31 days in June': true,
'a invalid date string with 32 days in July': true,
'a invalid date string with 32 days in August': true,
'a invalid date string with 31 days in September': true,
'a invalid date string with 32 days in October': true,
'a invalid date string with 31 days in November': true,
'a invalid date string with 32 days in December': true,
'a invalid date string with invalid month': true,
'invalid month': true,
'invalid month-day combination': true,
'2021 is not a leap year': true
}
},
'draft2020-12/optional/format/idn-email': {
'validation of an internationalized e-mail addresses': {
'an invalid idn e-mail address': true,
'an invalid e-mail address': true
}
},
'draft2020-12/optional/format/idn-hostname': {
'validation of internationalized host names': {
'illegal first char U+302E Hangul single dot tone mark': true,
'contains illegal char U+302E Hangul single dot tone mark': true,
'a host name with a component too long': true,
'invalid label, correct Punycode': true,
'invalid Punycode': true,
'U-label contains "--" in the 3rd and 4th position': true,
'U-label starts with a dash': true,
'U-label ends with a dash': true,
'U-label starts and ends with a dash': true,
'Begins with a Spacing Combining Mark': true,
'Begins with a Nonspacing Mark': true,
'Begins with an Enclosing Mark': true,
'Exceptions that are DISALLOWED, right-to-left chars': true,
'Exceptions that are DISALLOWED, left-to-right chars': true,
"MIDDLE DOT with no preceding 'l'": true,
'MIDDLE DOT with nothing preceding': true,
"MIDDLE DOT with no following 'l'": true,
'MIDDLE DOT with nothing following': true,
'Greek KERAIA not followed by Greek': true,
'Greek KERAIA not followed by anything': true,
'Hebrew GERESH not preceded by Hebrew': true,
'Hebrew GERESH not preceded by anything': true,
'Hebrew GERSHAYIM not preceded by Hebrew': true,
'Hebrew GERSHAYIM not preceded by anything': true,
'KATAKANA MIDDLE DOT with no Hiragana, Katakana, or Han': true,
'KATAKANA MIDDLE DOT with no other characters': true,
'Arabic-Indic digits mixed with Extended Arabic-Indic digits': true,
'ZERO WIDTH JOINER not preceded by Virama': true,
'ZERO WIDTH JOINER not preceded by anything': true
}
},
'draft2020-12/optional/format/ipv4': {
'validation of IP addresses': {
'leading zeroes should be rejected, as they are treated as octals': true
}
},
'draft2020-12/optional/format/iri-reference': {
'validation of IRI References': {
'an invalid IRI Reference': true,
'an invalid IRI fragment': true
}
},
'draft2020-12/optional/format/iri': {
'validation of IRIs': {
'an invalid IRI based on IPv6': true,
'an invalid relative IRI Reference': true,
'an invalid IRI': true,
'an invalid IRI though valid IRI reference': true
}
},
'draft2020-12/optional/format/time': {
'validation of time strings': {
'valid leap second, positive time-offset': true,
'valid leap second, large positive time-offset': true,
'invalid leap second, positive time-offset (wrong hour)': true,
'invalid leap second, positive time-offset (wrong minute)': true,
'valid leap second, negative time-offset': true,
'valid leap second, large negative time-offset': true,
'invalid leap second, negative time-offset (wrong hour)': true,
'invalid leap second, negative time-offset (wrong minute)': true,
'an invalid time string with invalid hour': true,
'an invalid time string with invalid time numoffset hour': true,
'an invalid time string with invalid time numoffset minute': true
}
},
'draft2020-12/optional/non-bmp-regex': {
'Proper UTF-16 surrogate pair handling: pattern': {
'matches empty': true,
'matches two': true
},
'Proper UTF-16 surrogate pair handling: patternProperties': {
"doesn't match two": true
}
},
'draft2020-12/optional/unicode': {
'unicode semantics should be used for all pattern matching': {
'literal unicode character in json string': true,
'unicode character in hex format in string': true
},
'unicode digits are more than 0 through 9': {
'non-ascii digits (BENGALI DIGIT FOUR, BENGALI DIGIT TWO)': true
},
'unicode semantics should be used for all patternProperties matching': {
'literal unicode character in json string': true,
'unicode character in hex format in string': true
}
},
'draft2020-12/ref': {
'relative pointer ref to array': {
'mismatch array': true
}
},
'draft4/optional/format/ipv4': {
'validation of IP addresses': {
'leading zeroes should be rejected, as they are treated as octals': true
}
},
'draft4/optional/non-bmp-regex': {
'Proper UTF-16 surrogate pair handling: pattern': {
'matches empty': true,
'matches two': true
},
'Proper UTF-16 surrogate pair handling: patternProperties': {
"doesn't match two": true
}
},
'draft4/optional/unicode': {
'unicode semantics should be used for all pattern matching': {
'literal unicode character in json string': true,
'unicode character in hex format in string': true
},
'unicode digits are more than 0 through 9': {
'non-ascii digits (BENGALI DIGIT FOUR, BENGALI DIGIT TWO)': true
},
'unicode semantics should be used for all patternProperties matching': {
'literal unicode character in json string': true,
'unicode character in hex format in string': true
}
},
'draft4/optional/zeroTerminatedFloats': {
'some languages do not distinguish between different types of numeric value':
{
'a float is not an integer even without fractional part': true
}
},
'draft7/optional/content': {
'validation of string-encoded content based on media type': {
'an invalid JSON document': true
},
'validation of binary string-encoding': {
'an invalid base64 string (% is not a valid character)': true
},
'validation of binary-encoded media type documents': {
'a validly-encoded invalid JSON document': true,
'an invalid base64 string that is valid JSON': true
}
},
'draft7/optional/format/date': {
'validation of date strings': {
'a invalid date string with 32 days in January': true,
'a invalid date string with 29 days in February (normal)': true,
'a invalid date string with 30 days in February (leap)': true,
'a invalid date string with 32 days in March': true,
'a invalid date string with 31 days in April': true,
'a invalid date string with 32 days in May': true,
'a invalid date string with 31 days in June': true,
'a invalid date string with 32 days in July': true,
'a invalid date string with 32 days in August': true,
'a invalid date string with 31 days in September': true,
'a invalid date string with 32 days in October': true,
'a invalid date string with 31 days in November': true,
'a invalid date string with 32 days in December': true,
'a invalid date string with invalid month': true,
'invalid month': true,
'invalid month-day combination': true,
'2021 is not a leap year': true
}
},
'draft7/optional/format/idn-email': {
'validation of an internationalized e-mail addresses': {
'an invalid idn e-mail address': true,
'an invalid e-mail address': true
}
},
'draft7/optional/format/idn-hostname': {
'validation of internationalized host names': {
'illegal first char U+302E Hangul single dot tone mark': true,
'contains illegal char U+302E Hangul single dot tone mark': true,
'a host name with a component too long': true,
'invalid label, correct Punycode': true,
'invalid Punycode': true,
'U-label contains "--" in the 3rd and 4th position': true,
'U-label starts with a dash': true,
'U-label ends with a dash': true,
'U-label starts and ends with a dash': true,
'Begins with a Spacing Combining Mark': true,
'Begins with a Nonspacing Mark': true,
'Begins with an Enclosing Mark': true,
'Exceptions that are DISALLOWED, right-to-left chars': true,
'Exceptions that are DISALLOWED, left-to-right chars': true,
"MIDDLE DOT with no preceding 'l'": true,
'MIDDLE DOT with nothing preceding': true,
"MIDDLE DOT with no following 'l'": true,
'MIDDLE DOT with nothing following': true,
'Greek KERAIA not followed by Greek': true,
'Greek KERAIA not followed by anything': true,
'Hebrew GERESH not preceded by Hebrew': true,
'Hebrew GERESH not preceded by anything': true,
'Hebrew GERSHAYIM not preceded by Hebrew': true,
'Hebrew GERSHAYIM not preceded by anything': true,
'KATAKANA MIDDLE DOT with no Hiragana, Katakana, or Han': true,
'KATAKANA MIDDLE DOT with no other characters': true,
'Arabic-Indic digits mixed with Extended Arabic-Indic digits': true,
'ZERO WIDTH JOINER not preceded by Virama': true,
'ZERO WIDTH JOINER not preceded by anything': true
}
},
'draft7/optional/format/ipv4': {
'validation of IP addresses': {
'leading zeroes should be rejected, as they are treated as octals': true
}
},
'draft7/optional/format/iri-reference': {
'validation of IRI References': {
'an invalid IRI Reference': true,
'an invalid IRI fragment': true
}
},
'draft7/optional/format/iri': {
'validation of IRIs': {
'an invalid IRI based on IPv6': true,
'an invalid relative IRI Reference': true,
'an invalid IRI': true,
'an invalid IRI though valid IRI reference': true
}
},
'draft7/optional/format/time': {
'validation of time strings': {
'valid leap second, positive time-offset': true,
'valid leap second, large positive time-offset': true,
'invalid leap second, positive time-offset (wrong hour)': true,
'invalid leap second, positive time-offset (wrong minute)': true,
'valid leap second, negative time-offset': true,
'valid leap second, large negative time-offset': true,
'invalid leap second, negative time-offset (wrong hour)': true,
'invalid leap second, negative time-offset (wrong minute)': true,
'an invalid time string with invalid hour': true,
'an invalid time string with invalid time numoffset hour': true,
'an invalid time string with invalid time numoffset minute': true
}
},
'draft7/optional/non-bmp-regex': {
'Proper UTF-16 surrogate pair handling: pattern': {
'matches empty': true,
'matches two': true
},
'Proper UTF-16 surrogate pair handling: patternProperties': {
"doesn't match two": true
}
},
'draft7/optional/unicode': {
'unicode semantics should be used for all pattern matching': {
'literal unicode character in json string': true,
'unicode character in hex format in string': true
},
'unicode digits are more than 0 through 9': {
'non-ascii digits (BENGALI DIGIT FOUR, BENGALI DIGIT TWO)': true
},
'unicode semantics should be used for all patternProperties matching': {
'literal unicode character in json string': true,
'unicode character in hex format in string': true
}
}
}

Wyświetl plik

@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import { Validator } from '../src/validator'
describe('Validator', () => {
it('validates', () => {
const validator = new Validator({ schema: { type: 'number' } })
expect(validator.validate(7).valid).to.equal(true)
expect(validator.validate('hello world').valid).to.equal(false)
})
it('adds schema', () => {
const validator = new Validator({
schema: {
$id: 'https://foo.bar/baz',
$ref: '/beep'
}
})
validator.addSchema({ $id: 'https://foo.bar/beep', type: 'boolean' })
expect(validator.validate(true).valid).to.equal(true)
expect(validator.validate('hello world').valid).to.equal(false)
})
it('adds schema with specified id', () => {
const validator = new Validator({
schema: {
$id: 'https://foo.bar/baz',
$ref: '/beep'
}
})
validator.addSchema({ type: 'boolean' }, 'https://foo.bar/beep')
expect(validator.validate(true).valid).to.equal(true)
expect(validator.validate('hello world').valid).to.equal(false)
})
it('validate all array entries with nested errors', () => {
const validator = new Validator({
schema: {
type: 'array',
items: {
name: { type: 'string' },
email: { type: 'string' },
required: ['name', 'email']
}
},
draft: '2019-09',
shortCircuit: false
})
const res = validator.validate([
{
name: 'hello'
//missing email
},
{
//missing name
email: 'a@b.c'
}
])
expect(res.valid).to.equal(false)
expect(res.errors.length).to.equal(4)
})
it('validate all object properties with nested errors', () => {
const validator = new Validator({
schema: {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string' },
number: { type: 'number' },
required: ['name', 'email', 'number']
}
},
draft: '2019-09',
shortCircuit: false
})
const res = validator.validate({
name: 'hello',
email: 5, //invalid type
number: 'Hello' //invalid type
})
expect(res.valid).to.equal(false)
expect(res.errors.length).to.equal(4)
})
})

Wyświetl plik

@ -0,0 +1,8 @@
{
"extends": "@fisch0920/config/tsconfig-node",
"compilerOptions": {
"lib": ["ESNext", "WebWorker", "Webworker.Iterable"]
},
"include": ["src", "test", "*.config.ts"],
"exclude": ["node_modules"]
}

Wyświetl plik

@ -1,4 +1,4 @@
export * from './parse-faas-identifier'
export * from './parse-faas-uri'
export * from './parse-tool-identifier'
export * from './parse-tool-uri'
export type * from './types'
export * as validators from './validators'

Wyświetl plik

@ -1,10 +1,10 @@
import type { ParsedFaasIdentifier } from './types'
import { parseFaasUri } from './parse-faas-uri'
import type { ParsedToolIdentifier } from './types'
import { parseToolUri } from './parse-faas-uri'
export function parseFaasIdentifier(
identifier: string,
{ namespace }: { namespace?: string } = {}
): ParsedFaasIdentifier | undefined {
export function parseToolIdentifier(
identifier: string
// { namespace }: { namespace?: string } = {}
): ParsedToolIdentifier | undefined {
if (!identifier) {
return
}
@ -22,17 +22,17 @@ export function parseFaasIdentifier(
return
}
const hasNamespacePrefix = /^([a-zA-Z0-9-]{1,64}\/)/.test(uri)
// const hasNamespacePrefix = /^([a-zA-Z0-9-]{1,64}\/)/.test(uri)
if (!hasNamespacePrefix) {
if (namespace) {
// add inferred namespace prefix (defaults to authenticated user's username)
uri = `${namespace}/${uri}`
} else {
// throw new Error(`FaaS identifier is missing namespace prefix or you must be authenticated [${uri}]`)
return
}
}
// if (!hasNamespacePrefix) {
// if (namespace) {
// // add inferred namespace prefix (defaults to authenticated user's username)
// uri = `${namespace}/${uri}`
// } else {
// // throw new Error(`FaaS identifier is missing namespace prefix or you must be authenticated [${uri}]`)
// return
// }
// }
return parseFaasUri(uri)
return parseToolUri(uri)
}

Wyświetl plik

@ -1,24 +1,21 @@
// TODO: investigate this
/* eslint-disable security/detect-unsafe-regex */
import type { ParsedFaasIdentifier } from './types'
import type { ParsedToolIdentifier } from './types'
// namespace/project-name@deploymentHash/toolPath
// project@deploymentHash/toolPath
const projectDeploymentToolRe =
/^([a-zA-Z0-9-]{1,64}\/[a-z0-9-]{2,64})@([a-z0-9]{8})(\/[a-zA-Z0-9\-._~%!$&'()*+,;=:/]*)?$/
// namespace/project-name@version/toolPath
// project@version/toolPath
const projectVersionToolRe =
/^([a-zA-Z0-9-]{1,64}\/[a-z0-9-]{2,64})@([^/?@]+)(\/[a-zA-Z0-9\-._~%!$&'()*+,;=:/]*)?$/
// namespace/project-name/toolPath
// project/toolPath (latest version)
// namespace/project-name/toolPath (latest version)
const projectToolRe =
/^([a-zA-Z0-9-]{1,64}\/[a-z0-9-]{2,64})(\/[a-zA-Z0-9\-._~%!$&'()*+,;=:/]*)?$/
export function parseFaasUri(uri: string): ParsedFaasIdentifier | undefined {
export function parseToolUri(uri: string): ParsedToolIdentifier | undefined {
const pdtMatch = uri.match(projectDeploymentToolRe)
if (pdtMatch) {

Wyświetl plik

@ -1,10 +1,10 @@
import { expect, test } from 'vitest'
import { parseFaasIdentifier } from './parse-faas-identifier'
import { parseToolIdentifier } from './parse-tool-identifier'
import * as validators from './validators'
function success(...args: Parameters<typeof parseFaasIdentifier>) {
const result = parseFaasIdentifier(...args)
function success(...args: Parameters<typeof parseToolIdentifier>) {
const result = parseToolIdentifier(...args)
expect(result).toBeTruthy()
expect(result!.projectIdentifier).toBeTruthy()
expect(result!.version || result!.deploymentHash).toBeTruthy()
@ -21,8 +21,8 @@ function success(...args: Parameters<typeof parseFaasIdentifier>) {
expect(result).toMatchSnapshot()
}
function error(...args: Parameters<typeof parseFaasIdentifier>) {
const result = parseFaasIdentifier(...args)
function error(...args: Parameters<typeof parseToolIdentifier>) {
const result = parseToolIdentifier(...args)
expect(result).toBeUndefined()
}
@ -61,19 +61,15 @@ test('URL prefix and suffix success', () => {
})
test('namespace success', () => {
success('https://api.saasify.sh/foo-bar@01234567/foo', {
namespace: 'username'
})
success('/foo-bar@01234567/foo', { namespace: 'username' })
success('/foo-bar@01234567/foo', { namespace: 'username' })
success('/foo-bar@01234567/foo/', { namespace: 'username' })
success('https://api.saasify.sh/foo-bar@01234567/foo/bar/123', {
namespace: 'username'
})
success('/foo-bar@01234567/foo/bar/123', { namespace: 'username' })
success('/foo-bar@latest/foo/bar/123', { namespace: 'username' })
success('/foo-bar@dev/foo/bar/123', { namespace: 'username' })
success('/foo-bar@1.2.3/foo/bar/123', { namespace: 'username' })
success('https://api.saasify.sh/username/foo-bar@01234567/foo')
success('/username/foo-bar@01234567/foo')
success('/username/foo-bar@01234567/foo')
success('/username/foo-bar@01234567/foo/')
success('username/https://api.saasify.sh/foo-bar@01234567/foo/bar/123')
success('/username/foo-bar@01234567/foo/bar/123')
success('/username/foo-bar@latest/foo/bar/123')
success('/username/foo-bar@dev/foo/bar/123')
success('/username/foo-bar@1.2.3/foo/bar/123')
})
test('namespace error', () => {
@ -82,6 +78,7 @@ test('namespace error', () => {
error('/foo-bar@01234567/foo')
error('/foo-bar@dev/foo')
error('/foo-bar@01234567/foo')
error('foo-bar/tool')
error('/foo-bar@01234567/foo/')
error('/foo-bar@01234567/foo/bar/123')
error('/foo-bar@0latest/foo/bar/123')

Wyświetl plik

@ -0,0 +1,38 @@
import type { ParsedToolIdentifier } from './types'
import { parseToolUri } from './parse-tool-uri'
export function parseToolIdentifier(
identifier: string
// { namespace }: { namespace?: string } = {}
): ParsedToolIdentifier | undefined {
if (!identifier) {
return
}
let uri = identifier
try {
const { pathname } = new URL(identifier)
uri = pathname
} catch {}
uri = uri.replaceAll(/^\//g, '')
uri = uri.replaceAll(/\/$/g, '')
if (!uri.length) {
return
}
// const hasNamespacePrefix = /^([a-zA-Z0-9-]{1,64}\/)/.test(uri)
// if (!hasNamespacePrefix) {
// if (namespace) {
// // add inferred namespace prefix (defaults to authenticated user's username)
// uri = `${namespace}/${uri}`
// } else {
// // throw new Error(`FaaS identifier is missing namespace prefix or you must be authenticated [${uri}]`)
// return
// }
// }
return parseToolUri(uri)
}

Wyświetl plik

@ -1,9 +1,9 @@
import { expect, test } from 'vitest'
import { parseFaasUri } from './parse-faas-uri'
import { parseToolUri } from './parse-tool-uri'
function success(value: string) {
const result = parseFaasUri(value)
const result = parseToolUri(value)
expect(result).toBeTruthy()
expect(result?.projectIdentifier).toBeTruthy()
expect(result?.version || result?.deploymentHash).toBeTruthy()
@ -11,7 +11,7 @@ function success(value: string) {
}
function error(value: string) {
const result = parseFaasUri(value)
const result = parseToolUri(value)
expect(result).toBeUndefined()
}

Wyświetl plik

@ -0,0 +1,56 @@
// TODO: investigate this
/* eslint-disable security/detect-unsafe-regex */
import type { ParsedToolIdentifier } from './types'
// namespace/project-name@deploymentHash/toolPath
const projectDeploymentToolRe =
/^([a-zA-Z0-9-]{1,64}\/[a-z0-9-]{2,64})@([a-z0-9]{8})(\/[a-zA-Z0-9\-._~%!$&'()*+,;=:/]*)?$/
// namespace/project-name@version/toolPath
const projectVersionToolRe =
/^([a-zA-Z0-9-]{1,64}\/[a-z0-9-]{2,64})@([^/?@]+)(\/[a-zA-Z0-9\-._~%!$&'()*+,;=:/]*)?$/
// namespace/project-name/toolPath (latest version)
const projectToolRe =
/^([a-zA-Z0-9-]{1,64}\/[a-z0-9-]{2,64})(\/[a-zA-Z0-9\-._~%!$&'()*+,;=:/]*)?$/
export function parseToolUri(uri: string): ParsedToolIdentifier | undefined {
const pdtMatch = uri.match(projectDeploymentToolRe)
if (pdtMatch) {
const projectIdentifier = pdtMatch[1]!
const deploymentHash = pdtMatch[2]!
const toolPath = pdtMatch[3] || '/'
return {
projectIdentifier,
deploymentHash,
toolPath,
deploymentIdentifier: `${projectIdentifier}@${deploymentHash}`
}
}
const pvtMatch = uri.match(projectVersionToolRe)
if (pvtMatch) {
return {
projectIdentifier: pvtMatch[1]!,
version: pvtMatch[2]!,
toolPath: pvtMatch[3] || '/'
}
}
const ptMatch = uri.match(projectToolRe)
if (ptMatch) {
return {
projectIdentifier: ptMatch[1]!,
toolPath: ptMatch[2] || '/',
version: 'latest'
}
}
// Invalid tool uri
return
}

Wyświetl plik

@ -1,4 +1,4 @@
export type ParsedFaasIdentifier = {
export type ParsedToolIdentifier = {
projectIdentifier: string
deploymentHash?: string
deploymentIdentifier?: string

Wyświetl plik

@ -521,6 +521,12 @@ importers:
specifier: workspace:*
version: link:../platform
packages/json-schema:
devDependencies:
json-schema-test-suite:
specifier: git+https://github.com/json-schema-org/JSON-Schema-Test-Suite#76b529f
version: https://codeload.github.com/json-schema-org/JSON-Schema-Test-Suite/tar.gz/76b529f
packages/openapi-utils:
dependencies:
'@agentic/platform-core':
@ -3379,6 +3385,10 @@ packages:
resolution: {integrity: sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==}
engines: {node: ^18.17.0 || >=20.5.0}
json-schema-test-suite@https://codeload.github.com/json-schema-org/JSON-Schema-Test-Suite/tar.gz/76b529f:
resolution: {tarball: https://codeload.github.com/json-schema-org/JSON-Schema-Test-Suite/tar.gz/76b529f}
version: 0.1.0
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@ -7417,6 +7427,8 @@ snapshots:
json-parse-even-better-errors@4.0.0: {}
json-schema-test-suite@https://codeload.github.com/json-schema-org/JSON-Schema-Test-Suite/tar.gz/76b529f: {}
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}

Wyświetl plik

@ -53,7 +53,7 @@
- how to handle binary bodies and responses?
- signed requests
- revisit deployment identifiers so possibly be URL-friendly?
- rename parseFaasIdentifier to `parseToolIdentifier` and move validators package into platform-types?
- rename parseToolIdentifier to `parseToolIdentifier` and move validators package into platform-types?
## License