diff --git a/apps/api/src/api-v1/consumers/schemas.ts b/apps/api/src/api-v1/consumers/schemas.ts index ab8cf2a6..d911957c 100644 --- a/apps/api/src/api-v1/consumers/schemas.ts +++ b/apps/api/src/api-v1/consumers/schemas.ts @@ -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({ diff --git a/apps/api/src/api-v1/deployments/list-deployments.ts b/apps/api/src/api-v1/deployments/list-deployments.ts index 8a7e29df..c3222450 100644 --- a/apps/api/src/api-v1/deployments/list-deployments.ts +++ b/apps/api/src/api-v1/deployments/list-deployments.ts @@ -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) diff --git a/apps/api/src/api-v1/deployments/schemas.ts b/apps/api/src/api-v1/deployments/schemas.ts index ee332ddc..5bdea9e9 100644 --- a/apps/api/src/api-v1/deployments/schemas.ts +++ b/apps/api/src/api-v1/deployments/schemas.ts @@ -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({ diff --git a/apps/api/src/api-v1/projects/schemas.ts b/apps/api/src/api-v1/projects/schemas.ts index 568034b4..f455c9a4 100644 --- a/apps/api/src/api-v1/projects/schemas.ts +++ b/apps/api/src/api-v1/projects/schemas.ts @@ -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({ diff --git a/apps/api/src/db/schemas.ts b/apps/api/src/db/schemas.ts index b9479c6b..34ff551d 100644 --- a/apps/api/src/db/schemas.ts +++ b/apps/api/src/db/schemas.ts @@ -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}")' ) diff --git a/apps/api/src/lib/deployments/try-get-deployment-by-identifier.ts b/apps/api/src/lib/deployments/try-get-deployment-by-identifier.ts index e3b80db3..37bd42e6 100644 --- a/apps/api/src/lib/deployments/try-get-deployment-by-identifier.ts +++ b/apps/api/src/lib/deployments/try-get-deployment-by-identifier.ts @@ -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 { 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, diff --git a/apps/api/src/lib/projects/try-get-project-by-identifier.ts b/apps/api/src/lib/projects/try-get-project-by-identifier.ts index ee1c11ca..58b776f4 100644 --- a/apps/api/src/lib/projects/try-get-project-by-identifier.ts +++ b/apps/api/src/lib/projects/try-get-project-by-identifier.ts @@ -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 { 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, diff --git a/apps/e2e/src/__snapshots__/e2e.test.ts.snap b/apps/e2e/src/__snapshots__/e2e.test.ts.snap new file mode 100644 index 00000000..80c2c87e --- /dev/null +++ b/apps/e2e/src/__snapshots__/e2e.test.ts.snap @@ -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, +} +`; diff --git a/apps/e2e/src/e2e.test.ts b/apps/e2e/src/e2e.test.ts index 41cb630f..ba7a4ed8 100644 --- a/apps/e2e/src/e2e.test.ts +++ b/apps/e2e/src/e2e.test.ts @@ -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()) + }) } ) } diff --git a/apps/e2e/src/fixtures.ts b/apps/e2e/src/fixtures.ts index 7b111984..31689023 100644 --- a/apps/e2e/src/fixtures.ts +++ b/apps/e2e/src/fixtures.ts @@ -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 + } + } } ] diff --git a/apps/gateway/package.json b/apps/gateway/package.json index e5166b73..5333f0e6 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -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:", diff --git a/apps/gateway/src/lib/cf-validate-json-schema-object.ts b/apps/gateway/src/lib/cf-validate-json-schema-object.ts index 45319179..6bfbf6c6 100644 --- a/apps/gateway/src/lib/cf-validate-json-schema-object.ts +++ b/apps/gateway/src/lib/cf-validate-json-schema-object.ts @@ -18,11 +18,13 @@ export function cfValidateJsonSchemaObject< >({ schema, data, - errorMessage + errorMessage, + coerce = true }: { schema: any data: Record 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 diff --git a/apps/gateway/src/lib/fetch-cache.ts b/apps/gateway/src/lib/fetch-cache.ts index b81adb42..ca5c608c 100644 --- a/apps/gateway/src/lib/fetch-cache.ts +++ b/apps/gateway/src/lib/fetch-cache.ts @@ -10,6 +10,7 @@ export async function fetchCache( fetchResponse: () => Promise } ): Promise { + 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') diff --git a/apps/gateway/src/lib/get-admin-deployment.ts b/apps/gateway/src/lib/get-admin-deployment.ts index 702d9c4d..b5ab2721 100644 --- a/apps/gateway/src/lib/get-admin-deployment.ts +++ b/apps/gateway/src/lib/get-admin-deployment.ts @@ -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({ diff --git a/apps/gateway/src/worker.ts b/apps/gateway/src/worker.ts index 8d54dac9..92464298 100644 --- a/apps/gateway/src/worker.ts +++ b/apps/gateway/src/worker.ts @@ -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') diff --git a/eslint.config.js b/eslint.config.js index ec4d424c..3609c735 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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' ] }, { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index e73cc5be..fc87e270 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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) diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index f666b7fa..ce37af87 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -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}"`) diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 0fc28572..a651ee64 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -21,7 +21,7 @@ export function registerPublishCommand({ client, program, logger }: Context) { AuthStore.requireAuth() if (deploymentIdentifier) { - // TODO: parseFaasIdentifier + // TODO: parseToolIdentifier } const deployment = await oraPromise( diff --git a/packages/cli/src/lib/resolve-deployment.ts b/packages/cli/src/lib/resolve-deployment.ts index 1d9f53cc..92cfc11f 100644 --- a/packages/cli/src/lib/resolve-deployment.ts +++ b/packages/cli/src/lib/resolve-deployment.ts @@ -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}` } diff --git a/packages/fixtures/valid/basic-openapi/jsonplaceholder.json b/packages/fixtures/valid/basic-openapi/jsonplaceholder.json index 67925935..3e25e765 100644 --- a/packages/fixtures/valid/basic-openapi/jsonplaceholder.json +++ b/packages/fixtures/valid/basic-openapi/jsonplaceholder.json @@ -72,7 +72,7 @@ { "required": true, "schema": { - "type": "string" + "type": "integer" }, "name": "postId", "in": "path" diff --git a/packages/json-schema/.gitignore b/packages/json-schema/.gitignore new file mode 100644 index 00000000..34e2cd89 --- /dev/null +++ b/packages/json-schema/.gitignore @@ -0,0 +1 @@ +test/json-schema-test-suite.ts \ No newline at end of file diff --git a/packages/json-schema/package.json b/packages/json-schema/package.json new file mode 100644 index 00000000..13eb5e34 --- /dev/null +++ b/packages/json-schema/package.json @@ -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 ", + "Travis Fischer " + ], + "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" + } +} diff --git a/packages/json-schema/readme.md b/packages/json-schema/readme.md new file mode 100644 index 00000000..60f0b912 --- /dev/null +++ b/packages/json-schema/readme.md @@ -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 +}); +``` diff --git a/packages/json-schema/src/coercion.ts b/packages/json-schema/src/coercion.ts new file mode 100644 index 00000000..ef63b63f --- /dev/null +++ b/packages/json-schema/src/coercion.ts @@ -0,0 +1,195 @@ +import type { InstanceType } from './types' + +export function getInstanceType( + instance: any +): Exclude { + 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 + 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 + }) +} diff --git a/packages/json-schema/src/deep-compare-strict.ts b/packages/json-schema/src/deep-compare-strict.ts new file mode 100644 index 00000000..2e835a8a --- /dev/null +++ b/packages/json-schema/src/deep-compare-strict.ts @@ -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 +} diff --git a/packages/json-schema/src/dereference.ts b/packages/json-schema/src/dereference.ts new file mode 100644 index 00000000..8d2db121 --- /dev/null +++ b/packages/json-schema/src/dereference.ts @@ -0,0 +1,189 @@ +/* eslint-disable unicorn/no-thenable */ +import type { Schema } from './types' +import { encodePointer } from './pointer' + +export const schemaKeyword: Record = { + 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 = { + prefixItems: true, + items: true, + allOf: true, + anyOf: true, + oneOf: true +} + +export const schemaMapKeyword: Record = { + $defs: true, + definitions: true, + properties: true, + patternProperties: true, + dependentSchemas: true +} + +export const ignoredKeyword: Record = { + 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 = Object.create(null), + baseURI: URL = initialBaseURI, + basePointer = '' +): Record { + 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 diff --git a/packages/json-schema/src/format.ts b/packages/json-schema/src/format.ts new file mode 100644 index 00000000..44bc8420 --- /dev/null +++ b/packages/json-schema/src/format.ts @@ -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 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 + } +} diff --git a/packages/json-schema/src/index.ts b/packages/json-schema/src/index.ts new file mode 100644 index 00000000..fc91fbe4 --- /dev/null +++ b/packages/json-schema/src/index.ts @@ -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' diff --git a/packages/json-schema/src/pointer.ts b/packages/json-schema/src/pointer.ts new file mode 100644 index 00000000..41e96f1f --- /dev/null +++ b/packages/json-schema/src/pointer.ts @@ -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') +} diff --git a/packages/json-schema/src/types.ts b/packages/json-schema/src/types.ts new file mode 100644 index 00000000..255fdbec --- /dev/null +++ b/packages/json-schema/src/types.ts @@ -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 + + 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 + patternProperties?: Record + additionalProperties?: Schema | boolean + unevaluatedProperties?: Schema | boolean + minProperties?: number + maxProperties?: number + propertyNames?: Schema + dependentRequired?: Record + dependentSchemas?: Record + dependencies?: Record + + prefixItems?: Array + items?: Schema | boolean | Array + 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 +} diff --git a/packages/json-schema/src/ucs2-length.ts b/packages/json-schema/src/ucs2-length.ts new file mode 100644 index 00000000..0e118f78 --- /dev/null +++ b/packages/json-schema/src/ucs2-length.ts @@ -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 +} diff --git a/packages/json-schema/src/validate.ts b/packages/json-schema/src/validate.ts new file mode 100644 index 00000000..55e66693 --- /dev/null +++ b/packages/json-schema/src/validate.ts @@ -0,0 +1,1189 @@ +/* eslint-disable security/detect-non-literal-regexp */ +import type { OutputUnit, Schema, SchemaDraft, ValidationResult } from './types' +import { coerceValue, getInstanceType } from './coercion' +import { deepCompareStrict } from './deep-compare-strict' +import { dereference } from './dereference' +import { format } from './format' +import { encodePointer } from './pointer' +import { ucs2length } from './ucs2-length' + +export type Evaluated = Record + +export function validate( + instance: any, + schema: Schema | boolean, + draft: SchemaDraft = '2019-09', + lookup: Record = dereference(schema), + coerce = false, + shortCircuit = true, + recursiveAnchor: Schema | null = null, + instanceLocation = '#', + schemaLocation = '#', + evaluated: Evaluated = Object.create(null) +): ValidationResult { + if (schema === true) { + return { valid: true, errors: [], instance } + } + + if (schema === false) { + return { + valid: false, + instance, + errors: [ + { + instanceLocation, + keyword: 'false', + keywordLocation: instanceLocation, + error: 'False boolean schema.' + } + ] + } + } + + let instanceType = getInstanceType(instance) + + const { + $ref, + $recursiveRef, + $recursiveAnchor, + type: $type, + const: $const, + enum: $enum, + required: $required, + not: $not, + anyOf: $anyOf, + allOf: $allOf, + oneOf: $oneOf, + if: $if, + then: $then, + else: $else, + + format: $format, + + properties: $properties, + patternProperties: $patternProperties, + additionalProperties: $additionalProperties, + unevaluatedProperties: $unevaluatedProperties, + minProperties: $minProperties, + maxProperties: $maxProperties, + propertyNames: $propertyNames, + dependentRequired: $dependentRequired, + dependentSchemas: $dependentSchemas, + dependencies: $dependencies, + + prefixItems: $prefixItems, + items: $items, + additionalItems: $additionalItems, + unevaluatedItems: $unevaluatedItems, + contains: $contains, + minContains: $minContains, + maxContains: $maxContains, + minItems: $minItems, + maxItems: $maxItems, + uniqueItems: $uniqueItems, + + minimum: $minimum, + maximum: $maximum, + exclusiveMinimum: $exclusiveMinimum, + exclusiveMaximum: $exclusiveMaximum, + multipleOf: $multipleOf, + + minLength: $minLength, + maxLength: $maxLength, + pattern: $pattern, + + // eslint-disable-next-line @typescript-eslint/naming-convention + __absolute_ref__, + + // eslint-disable-next-line @typescript-eslint/naming-convention + __absolute_recursive_ref__ + } = schema + + const errors: OutputUnit[] = [] + + if ($recursiveAnchor === true && recursiveAnchor === null) { + recursiveAnchor = schema + } + + if ($recursiveRef === '#') { + const refSchema = + recursiveAnchor === null + ? (lookup[__absolute_recursive_ref__!] as Schema) + : recursiveAnchor + const keywordLocation = `${schemaLocation}/$recursiveRef` + const result = validate( + instance, + recursiveAnchor === null ? schema : recursiveAnchor, + draft, + lookup, + coerce, + shortCircuit, + refSchema, + instanceLocation, + keywordLocation, + evaluated + ) + if (!result.valid) { + errors.push( + { + instanceLocation, + keyword: '$recursiveRef', + keywordLocation, + error: 'A subschema had errors.' + }, + ...result.errors + ) + } + } + + if ($ref !== undefined) { + const uri = __absolute_ref__ || $ref + const refSchema = lookup[uri] + if (refSchema === undefined) { + let message = `Unresolved $ref "${$ref}".` + if (__absolute_ref__ && __absolute_ref__ !== $ref) { + message += ` Absolute URI "${__absolute_ref__}".` + } + message += `\nKnown schemas:\n- ${Object.keys(lookup).join('\n- ')}` + throw new Error(message) + } + const keywordLocation = `${schemaLocation}/$ref` + const result = validate( + instance, + refSchema, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + instanceLocation, + keywordLocation, + evaluated + ) + if (!result.valid) { + errors.push( + { + instanceLocation, + keyword: '$ref', + keywordLocation, + error: 'A subschema had errors.' + }, + ...result.errors + ) + } + if (draft === '4' || draft === '7') { + return { valid: errors.length === 0, instance, errors } + } + } + + if (Array.isArray($type)) { + const length = $type.length + let valid = false + for (let i = 0; i < length; i++) { + if ( + instanceType === $type[i] || + ($type[i] === 'integer' && + instanceType === 'number' && + instance % 1 === 0 && + Number.isNaN(instance)) + ) { + valid = true + break + } + } + if (!valid) { + errors.push({ + instanceLocation, + keyword: 'type', + keywordLocation: `${schemaLocation}/type`, + error: `Instance type "${instanceType}" is invalid. Expected "${$type.join( + '", "' + )}".` + }) + } + } else if ($type === 'integer') { + if (instanceType !== 'number' || instance % 1 || Number.isNaN(instance)) { + const coercedInstance = coerce + ? coerceValue({ + instance, + instanceType, + $type + }) + : undefined + + if (coercedInstance !== undefined) { + instance = coercedInstance + instanceType = getInstanceType(instance) + } else { + errors.push({ + instanceLocation, + keyword: 'type', + keywordLocation: `${schemaLocation}/type`, + error: `Instance type "${instanceType}" is invalid. Expected "${$type}".` + }) + } + } + } else if ($type !== undefined && instanceType !== $type) { + const coercedInstance = coerce + ? coerceValue({ + instance, + instanceType, + $type + }) + : undefined + + if (coercedInstance !== undefined) { + instance = coercedInstance + instanceType = getInstanceType(instance) + } else { + errors.push({ + instanceLocation, + keyword: 'type', + keywordLocation: `${schemaLocation}/type`, + error: `Instance type "${instanceType}" is invalid. Expected "${$type}".` + }) + } + } + + if ($const !== undefined) { + if (instanceType === 'object' || instanceType === 'array') { + if (!deepCompareStrict(instance, $const)) { + errors.push({ + instanceLocation, + keyword: 'const', + keywordLocation: `${schemaLocation}/const`, + error: `Instance does not match ${JSON.stringify($const)}.` + }) + } + } else if (instance !== $const) { + errors.push({ + instanceLocation, + keyword: 'const', + keywordLocation: `${schemaLocation}/const`, + error: `Instance does not match ${JSON.stringify($const)}.` + }) + } + } + + if ($enum !== undefined) { + if (instanceType === 'object' || instanceType === 'array') { + if (!$enum.some((value) => deepCompareStrict(instance, value))) { + errors.push({ + instanceLocation, + keyword: 'enum', + keywordLocation: `${schemaLocation}/enum`, + error: `Instance does not match any of ${JSON.stringify($enum)}.` + }) + } + } else if (!$enum.includes(instance)) { + errors.push({ + instanceLocation, + keyword: 'enum', + keywordLocation: `${schemaLocation}/enum`, + error: `Instance does not match any of ${JSON.stringify($enum)}.` + }) + } + } + + if ($not !== undefined) { + const keywordLocation = `${schemaLocation}/not` + const result = validate( + instance, + $not, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + instanceLocation, + keywordLocation /*, + evaluated*/ + ) + if (result.valid) { + errors.push({ + instanceLocation, + keyword: 'not', + keywordLocation, + error: 'Instance matched "not" schema.' + }) + } + } + + const subEvaluateds: Array = [] + + if ($anyOf !== undefined) { + const keywordLocation = `${schemaLocation}/anyOf` + const errorsLength = errors.length + let anyValid = false + for (const [i, subSchema] of $anyOf.entries()) { + const subEvaluated: Evaluated = Object.create(evaluated) + const result = validate( + instance, + subSchema, + draft, + lookup, + coerce, + shortCircuit, + $recursiveAnchor === true ? recursiveAnchor : null, + instanceLocation, + `${keywordLocation}/${i}`, + subEvaluated + ) + errors.push(...result.errors) + anyValid = anyValid || result.valid + if (result.valid) { + subEvaluateds.push(subEvaluated) + } + } + if (anyValid) { + errors.length = errorsLength + } else { + errors.splice(errorsLength, 0, { + instanceLocation, + keyword: 'anyOf', + keywordLocation, + error: 'Instance does not match any subschemas.' + }) + } + } + + if ($allOf !== undefined) { + const keywordLocation = `${schemaLocation}/allOf` + const errorsLength = errors.length + let allValid = true + for (const [i, subSchema] of $allOf.entries()) { + const subEvaluated: Evaluated = Object.create(evaluated) + const result = validate( + instance, + subSchema, + draft, + lookup, + coerce, + shortCircuit, + $recursiveAnchor === true ? recursiveAnchor : null, + instanceLocation, + `${keywordLocation}/${i}`, + subEvaluated + ) + errors.push(...result.errors) + allValid = allValid && result.valid + if (result.valid) { + subEvaluateds.push(subEvaluated) + } + } + if (allValid) { + errors.length = errorsLength + } else { + errors.splice(errorsLength, 0, { + instanceLocation, + keyword: 'allOf', + keywordLocation, + error: `Instance does not match every subschema.` + }) + } + } + + if ($oneOf !== undefined) { + const keywordLocation = `${schemaLocation}/oneOf` + const errorsLength = errors.length + const matches = $oneOf.filter((subSchema, i) => { + const subEvaluated: Evaluated = Object.create(evaluated) + const result = validate( + instance, + subSchema, + draft, + lookup, + coerce, + shortCircuit, + $recursiveAnchor === true ? recursiveAnchor : null, + instanceLocation, + `${keywordLocation}/${i}`, + subEvaluated + ) + errors.push(...result.errors) + if (result.valid) { + subEvaluateds.push(subEvaluated) + } + return result.valid + }).length + if (matches === 1) { + errors.length = errorsLength + } else { + errors.splice(errorsLength, 0, { + instanceLocation, + keyword: 'oneOf', + keywordLocation, + error: `Instance does not match exactly one subschema (${matches} matches).` + }) + } + } + + if (instanceType === 'object' || instanceType === 'array') { + Object.assign(evaluated, ...subEvaluateds) + } + + if ($if !== undefined) { + const keywordLocation = `${schemaLocation}/if` + const conditionResult = validate( + instance, + $if, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + instanceLocation, + keywordLocation, + evaluated + ).valid + if (conditionResult) { + if ($then !== undefined) { + const thenResult = validate( + instance, + $then, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + instanceLocation, + `${schemaLocation}/then`, + evaluated + ) + if (!thenResult.valid) { + errors.push( + { + instanceLocation, + keyword: 'if', + keywordLocation, + error: `Instance does not match "then" schema.` + }, + ...thenResult.errors + ) + } + } + } else if ($else !== undefined) { + const elseResult = validate( + instance, + $else, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + instanceLocation, + `${schemaLocation}/else`, + evaluated + ) + if (!elseResult.valid) { + errors.push( + { + instanceLocation, + keyword: 'if', + keywordLocation, + error: `Instance does not match "else" schema.` + }, + ...elseResult.errors + ) + } + } + } + + if (instanceType === 'object') { + if ($required !== undefined) { + for (const key of $required) { + if (!(key in instance)) { + errors.push({ + instanceLocation, + keyword: 'required', + keywordLocation: `${schemaLocation}/required`, + error: `Instance does not have required property "${key}".` + }) + } + } + } + + const keys = Object.keys(instance) + + if ($minProperties !== undefined && keys.length < $minProperties) { + errors.push({ + instanceLocation, + keyword: 'minProperties', + keywordLocation: `${schemaLocation}/minProperties`, + error: `Instance does not have at least ${$minProperties} properties.` + }) + } + + if ($maxProperties !== undefined && keys.length > $maxProperties) { + errors.push({ + instanceLocation, + keyword: 'maxProperties', + keywordLocation: `${schemaLocation}/maxProperties`, + error: `Instance does not have at least ${$maxProperties} properties.` + }) + } + + if ($propertyNames !== undefined) { + const keywordLocation = `${schemaLocation}/propertyNames` + for (const key in instance) { + const subInstancePointer = `${instanceLocation}/${encodePointer(key)}` + const result = validate( + key, + $propertyNames, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + subInstancePointer, + keywordLocation + ) + if (!result.valid) { + errors.push( + { + instanceLocation, + keyword: 'propertyNames', + keywordLocation, + error: `Property name "${key}" does not match schema.` + }, + ...result.errors + ) + } + } + } + + if ($dependentRequired !== undefined) { + const keywordLocation = `${schemaLocation}/dependantRequired` + for (const key in $dependentRequired) { + if (key in instance) { + const required = $dependentRequired[key] as string[] + for (const dependantKey of required) { + if (!(dependantKey in instance)) { + errors.push({ + instanceLocation, + keyword: 'dependentRequired', + keywordLocation, + error: `Instance has "${key}" but does not have "${dependantKey}".` + }) + } + } + } + } + } + + if ($dependentSchemas !== undefined) { + for (const key in $dependentSchemas) { + const keywordLocation = `${schemaLocation}/dependentSchemas` + if (key in instance) { + const result = validate( + instance, + $dependentSchemas[key]!, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + instanceLocation, + `${keywordLocation}/${encodePointer(key)}`, + evaluated + ) + if (!result.valid) { + errors.push( + { + instanceLocation, + keyword: 'dependentSchemas', + keywordLocation, + error: `Instance has "${key}" but does not match dependant schema.` + }, + ...result.errors + ) + } + } + } + } + + if ($dependencies !== undefined) { + const keywordLocation = `${schemaLocation}/dependencies` + for (const key in $dependencies) { + if (key in instance) { + const propsOrSchema = $dependencies[key] as Schema | string[] + if (Array.isArray(propsOrSchema)) { + for (const dependantKey of propsOrSchema) { + if (!(dependantKey in instance)) { + errors.push({ + instanceLocation, + keyword: 'dependencies', + keywordLocation, + error: `Instance has "${key}" but does not have "${dependantKey}".` + }) + } + } + } else { + const result = validate( + instance, + propsOrSchema, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + instanceLocation, + `${keywordLocation}/${encodePointer(key)}` + ) + if (!result.valid) { + errors.push( + { + instanceLocation, + keyword: 'dependencies', + keywordLocation, + error: `Instance has "${key}" but does not match dependant schema.` + }, + ...result.errors + ) + } + } + } + } + } + + const thisEvaluated = Object.create(null) + + let stop = false + + if ($properties !== undefined) { + const keywordLocation = `${schemaLocation}/properties` + for (const key in $properties) { + if (!(key in instance)) { + continue + } + const subInstancePointer = `${instanceLocation}/${encodePointer(key)}` + const result = validate( + instance[key], + $properties[key]!, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + subInstancePointer, + `${keywordLocation}/${encodePointer(key)}` + ) + if (result.valid) { + evaluated[key] = thisEvaluated[key] = true + } else { + stop = shortCircuit + errors.push( + { + instanceLocation, + keyword: 'properties', + keywordLocation, + error: `Property "${key}" does not match schema.` + }, + ...result.errors + ) + if (stop) break + } + } + } + + if (!stop && $patternProperties !== undefined) { + const keywordLocation = `${schemaLocation}/patternProperties` + for (const pattern in $patternProperties) { + const regex = new RegExp(pattern, 'u') + const subSchema = $patternProperties[pattern] + for (const key in instance) { + if (!regex.test(key)) { + continue + } + const subInstancePointer = `${instanceLocation}/${encodePointer(key)}` + const result = validate( + instance[key], + subSchema!, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + subInstancePointer, + `${keywordLocation}/${encodePointer(pattern)}` + ) + if (result.valid) { + evaluated[key] = thisEvaluated[key] = true + } else { + stop = shortCircuit + errors.push( + { + instanceLocation, + keyword: 'patternProperties', + keywordLocation, + error: `Property "${key}" matches pattern "${pattern}" but does not match associated schema.` + }, + ...result.errors + ) + } + } + } + } + + if (!stop && $additionalProperties !== undefined) { + const keywordLocation = `${schemaLocation}/additionalProperties` + for (const key in instance) { + if (thisEvaluated[key]) { + continue + } + const subInstancePointer = `${instanceLocation}/${encodePointer(key)}` + const result = validate( + instance[key], + $additionalProperties, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + subInstancePointer, + keywordLocation + ) + if (result.valid) { + evaluated[key] = true + } else { + stop = shortCircuit + errors.push( + { + instanceLocation, + keyword: 'additionalProperties', + keywordLocation, + error: `Property "${key}" does not match additional properties schema.` + }, + ...result.errors + ) + } + } + } else if (!stop && $unevaluatedProperties !== undefined) { + const keywordLocation = `${schemaLocation}/unevaluatedProperties` + for (const key in instance) { + if (!evaluated[key]) { + const subInstancePointer = `${instanceLocation}/${encodePointer(key)}` + const result = validate( + instance[key], + $unevaluatedProperties, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + subInstancePointer, + keywordLocation + ) + if (result.valid) { + evaluated[key] = true + } else { + errors.push( + { + instanceLocation, + keyword: 'unevaluatedProperties', + keywordLocation, + error: `Property "${key}" does not match unevaluated properties schema.` + }, + ...result.errors + ) + } + } + } + } + } else if (instanceType === 'array') { + if ($maxItems !== undefined && instance.length > $maxItems) { + errors.push({ + instanceLocation, + keyword: 'maxItems', + keywordLocation: `${schemaLocation}/maxItems`, + error: `Array has too many items (${instance.length} > ${$maxItems}).` + }) + } + + if ($minItems !== undefined && instance.length < $minItems) { + errors.push({ + instanceLocation, + keyword: 'minItems', + keywordLocation: `${schemaLocation}/minItems`, + error: `Array has too few items (${instance.length} < ${$minItems}).` + }) + } + + const length: number = instance.length + let i = 0 + let stop = false + + if ($prefixItems !== undefined) { + const keywordLocation = `${schemaLocation}/prefixItems` + const length2 = Math.min($prefixItems.length, length) + for (; i < length2; i++) { + const result = validate( + instance[i], + $prefixItems[i]!, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + `${instanceLocation}/${i}`, + `${keywordLocation}/${i}` + ) + evaluated[i] = true + if (!result.valid) { + stop = shortCircuit + errors.push( + { + instanceLocation, + keyword: 'prefixItems', + keywordLocation, + error: `Items did not match schema.` + }, + ...result.errors + ) + if (stop) break + } + } + } + + if ($items !== undefined) { + const keywordLocation = `${schemaLocation}/items` + if (Array.isArray($items)) { + const length2 = Math.min($items.length, length) + for (; i < length2; i++) { + const result = validate( + instance[i], + $items[i]!, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + `${instanceLocation}/${i}`, + `${keywordLocation}/${i}` + ) + evaluated[i] = true + if (!result.valid) { + stop = shortCircuit + errors.push( + { + instanceLocation, + keyword: 'items', + keywordLocation, + error: `Items did not match schema.` + }, + ...result.errors + ) + if (stop) break + } + } + } else { + for (; i < length; i++) { + const result = validate( + instance[i], + $items, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + `${instanceLocation}/${i}`, + keywordLocation + ) + evaluated[i] = true + if (!result.valid) { + stop = shortCircuit + errors.push( + { + instanceLocation, + keyword: 'items', + keywordLocation, + error: `Items did not match schema.` + }, + ...result.errors + ) + if (stop) break + } + } + } + + if (!stop && $additionalItems !== undefined) { + const keywordLocation = `${schemaLocation}/additionalItems` + for (; i < length; i++) { + const result = validate( + instance[i], + $additionalItems, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + `${instanceLocation}/${i}`, + keywordLocation + ) + evaluated[i] = true + if (!result.valid) { + stop = shortCircuit + errors.push( + { + instanceLocation, + keyword: 'additionalItems', + keywordLocation, + error: `Items did not match additional items schema.` + }, + ...result.errors + ) + } + } + } + } + + if ($contains !== undefined) { + if (length === 0 && $minContains === undefined) { + errors.push({ + instanceLocation, + keyword: 'contains', + keywordLocation: `${schemaLocation}/contains`, + error: `Array is empty. It must contain at least one item matching the schema.` + }) + } else if ($minContains !== undefined && length < $minContains) { + errors.push({ + instanceLocation, + keyword: 'minContains', + keywordLocation: `${schemaLocation}/minContains`, + error: `Array has less items (${length}) than minContains (${$minContains}).` + }) + } else { + const keywordLocation = `${schemaLocation}/contains` + const errorsLength = errors.length + let contained = 0 + for (let j = 0; j < length; j++) { + const result = validate( + instance[j], + $contains, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + `${instanceLocation}/${j}`, + keywordLocation + ) + if (result.valid) { + evaluated[j] = true + contained++ + } else { + errors.push(...result.errors) + } + } + + if (contained >= ($minContains || 0)) { + errors.length = errorsLength + } + + if ( + $minContains === undefined && + $maxContains === undefined && + contained === 0 + ) { + errors.splice(errorsLength, 0, { + instanceLocation, + keyword: 'contains', + keywordLocation, + error: `Array does not contain item matching schema.` + }) + } else if ($minContains !== undefined && contained < $minContains) { + errors.push({ + instanceLocation, + keyword: 'minContains', + keywordLocation: `${schemaLocation}/minContains`, + error: `Array must contain at least ${$minContains} items matching schema. Only ${contained} items were found.` + }) + } else if ($maxContains !== undefined && contained > $maxContains) { + errors.push({ + instanceLocation, + keyword: 'maxContains', + keywordLocation: `${schemaLocation}/maxContains`, + error: `Array may contain at most ${$maxContains} items matching schema. ${contained} items were found.` + }) + } + } + } + + if (!stop && $unevaluatedItems !== undefined) { + const keywordLocation = `${schemaLocation}/unevaluatedItems` + for (i; i < length; i++) { + if (evaluated[i]) { + continue + } + const result = validate( + instance[i], + $unevaluatedItems, + draft, + lookup, + coerce, + shortCircuit, + recursiveAnchor, + `${instanceLocation}/${i}`, + keywordLocation + ) + evaluated[i] = true + if (!result.valid) { + errors.push( + { + instanceLocation, + keyword: 'unevaluatedItems', + keywordLocation, + error: `Items did not match unevaluated items schema.` + }, + ...result.errors + ) + } + } + } + + if ($uniqueItems) { + for (let j = 0; j < length; j++) { + const a = instance[j] + const ao = typeof a === 'object' && a !== null + for (let k = 0; k < length; k++) { + if (j === k) { + continue + } + const b = instance[k] + const bo = typeof b === 'object' && b !== null + if (a === b || (ao && bo && deepCompareStrict(a, b))) { + errors.push({ + instanceLocation, + keyword: 'uniqueItems', + keywordLocation: `${schemaLocation}/uniqueItems`, + error: `Duplicate items at indexes ${j} and ${k}.` + }) + j = Number.MAX_SAFE_INTEGER + k = Number.MAX_SAFE_INTEGER + } + } + } + } + } else if (instanceType === 'number') { + if (draft === '4') { + if ( + $minimum !== undefined && + (($exclusiveMinimum === true && instance <= $minimum) || + instance < $minimum) + ) { + errors.push({ + instanceLocation, + keyword: 'minimum', + keywordLocation: `${schemaLocation}/minimum`, + error: `${instance} is less than ${ + $exclusiveMinimum ? 'or equal to ' : '' + } ${$minimum}.` + }) + } + if ( + $maximum !== undefined && + (($exclusiveMaximum === true && instance >= $maximum) || + instance > $maximum) + ) { + errors.push({ + instanceLocation, + keyword: 'maximum', + keywordLocation: `${schemaLocation}/maximum`, + error: `${instance} is greater than ${ + $exclusiveMaximum ? 'or equal to ' : '' + } ${$maximum}.` + }) + } + } else { + if ($minimum !== undefined && instance < $minimum) { + errors.push({ + instanceLocation, + keyword: 'minimum', + keywordLocation: `${schemaLocation}/minimum`, + error: `${instance} is less than ${$minimum}.` + }) + } + if ($maximum !== undefined && instance > $maximum) { + errors.push({ + instanceLocation, + keyword: 'maximum', + keywordLocation: `${schemaLocation}/maximum`, + error: `${instance} is greater than ${$maximum}.` + }) + } + if ($exclusiveMinimum !== undefined && instance <= $exclusiveMinimum) { + errors.push({ + instanceLocation, + keyword: 'exclusiveMinimum', + keywordLocation: `${schemaLocation}/exclusiveMinimum`, + error: `${instance} is less than ${$exclusiveMinimum}.` + }) + } + if ($exclusiveMaximum !== undefined && instance >= $exclusiveMaximum) { + errors.push({ + instanceLocation, + keyword: 'exclusiveMaximum', + keywordLocation: `${schemaLocation}/exclusiveMaximum`, + error: `${instance} is greater than or equal to ${$exclusiveMaximum}.` + }) + } + } + if ($multipleOf !== undefined) { + const remainder = instance % $multipleOf + if ( + Math.abs(0 - remainder) >= 1.192_092_9e-7 && + Math.abs($multipleOf - remainder) >= 1.192_092_9e-7 + ) { + errors.push({ + instanceLocation, + keyword: 'multipleOf', + keywordLocation: `${schemaLocation}/multipleOf`, + error: `${instance} is not a multiple of ${$multipleOf}.` + }) + } + } + } else if (instanceType === 'string') { + const length = + $minLength === undefined && $maxLength === undefined + ? 0 + : ucs2length(instance) + if ($minLength !== undefined && length < $minLength) { + errors.push({ + instanceLocation, + keyword: 'minLength', + keywordLocation: `${schemaLocation}/minLength`, + error: `String is too short (${length} < ${$minLength}).` + }) + } + if ($maxLength !== undefined && length > $maxLength) { + errors.push({ + instanceLocation, + keyword: 'maxLength', + keywordLocation: `${schemaLocation}/maxLength`, + error: `String is too long (${length} > ${$maxLength}).` + }) + } + if ($pattern !== undefined && !new RegExp($pattern, 'u').test(instance)) { + errors.push({ + instanceLocation, + keyword: 'pattern', + keywordLocation: `${schemaLocation}/pattern`, + error: `String does not match pattern.` + }) + } + if ( + $format !== undefined && + format[$format] && + !format[$format](instance) + ) { + errors.push({ + instanceLocation, + keyword: 'format', + keywordLocation: `${schemaLocation}/format`, + error: `String does not match format "${$format}".` + }) + } + } + + return { valid: errors.length === 0, instance, errors } +} diff --git a/packages/json-schema/src/validator.ts b/packages/json-schema/src/validator.ts new file mode 100644 index 00000000..ec2089d8 --- /dev/null +++ b/packages/json-schema/src/validator.ts @@ -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 + 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) + } +} diff --git a/packages/json-schema/test/coercion.test.ts b/packages/json-schema/test/coercion.test.ts new file mode 100644 index 00000000..340a3667 --- /dev/null +++ b/packages/json-schema/test/coercion.test.ts @@ -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) + }) +}) diff --git a/packages/json-schema/test/index.test.ts b/packages/json-schema/test/index.test.ts new file mode 100644 index 00000000..aef4b658 --- /dev/null +++ b/packages/json-schema/test/index.test.ts @@ -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>> = {} + 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))); +}) diff --git a/packages/json-schema/test/meta-schema.ts b/packages/json-schema/test/meta-schema.ts new file mode 100644 index 00000000..e4efb351 --- /dev/null +++ b/packages/json-schema/test/meta-schema.ts @@ -0,0 +1,41 @@ +import { dereference, type Schema } from '../src/index' + +let lookup: Record | 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 +} diff --git a/packages/json-schema/test/types.ts b/packages/json-schema/test/types.ts new file mode 100644 index 00000000..7d5cfdf9 --- /dev/null +++ b/packages/json-schema/test/types.ts @@ -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 +} diff --git a/packages/json-schema/test/unsupported.ts b/packages/json-schema/test/unsupported.ts new file mode 100644 index 00000000..bd6d11d7 --- /dev/null +++ b/packages/json-schema/test/unsupported.ts @@ -0,0 +1,593 @@ +export const unsupportedTests: Record< + string, + Record> +> = { + '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 + } + } +} diff --git a/packages/json-schema/test/validator.spec.ts b/packages/json-schema/test/validator.spec.ts new file mode 100644 index 00000000..56a79c60 --- /dev/null +++ b/packages/json-schema/test/validator.spec.ts @@ -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) + }) +}) diff --git a/packages/json-schema/tsconfig.json b/packages/json-schema/tsconfig.json new file mode 100644 index 00000000..7752c210 --- /dev/null +++ b/packages/json-schema/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@fisch0920/config/tsconfig-node", + "compilerOptions": { + "lib": ["ESNext", "WebWorker", "Webworker.Iterable"] + }, + "include": ["src", "test", "*.config.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index 84c10dd7..4109ffd3 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -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' diff --git a/packages/validators/src/parse-faas-identifier.ts b/packages/validators/src/parse-faas-identifier.ts index a15ac134..13f0d6b6 100644 --- a/packages/validators/src/parse-faas-identifier.ts +++ b/packages/validators/src/parse-faas-identifier.ts @@ -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) } diff --git a/packages/validators/src/parse-faas-uri.ts b/packages/validators/src/parse-faas-uri.ts index 3017afda..bf9237f6 100644 --- a/packages/validators/src/parse-faas-uri.ts +++ b/packages/validators/src/parse-faas-uri.ts @@ -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) { diff --git a/packages/validators/src/parse-faas-identifier.test.ts b/packages/validators/src/parse-tool-identifier.test.ts similarity index 70% rename from packages/validators/src/parse-faas-identifier.test.ts rename to packages/validators/src/parse-tool-identifier.test.ts index 818f7b4f..9292caaa 100644 --- a/packages/validators/src/parse-faas-identifier.test.ts +++ b/packages/validators/src/parse-tool-identifier.test.ts @@ -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) { - const result = parseFaasIdentifier(...args) +function success(...args: Parameters) { + 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) { expect(result).toMatchSnapshot() } -function error(...args: Parameters) { - const result = parseFaasIdentifier(...args) +function error(...args: Parameters) { + 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') diff --git a/packages/validators/src/parse-tool-identifier.ts b/packages/validators/src/parse-tool-identifier.ts new file mode 100644 index 00000000..bbe47afe --- /dev/null +++ b/packages/validators/src/parse-tool-identifier.ts @@ -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) +} diff --git a/packages/validators/src/parse-faas-uri.test.ts b/packages/validators/src/parse-tool-uri.test.ts similarity index 96% rename from packages/validators/src/parse-faas-uri.test.ts rename to packages/validators/src/parse-tool-uri.test.ts index 77f5054e..b2fd695f 100644 --- a/packages/validators/src/parse-faas-uri.test.ts +++ b/packages/validators/src/parse-tool-uri.test.ts @@ -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() } diff --git a/packages/validators/src/parse-tool-uri.ts b/packages/validators/src/parse-tool-uri.ts new file mode 100644 index 00000000..bcf137eb --- /dev/null +++ b/packages/validators/src/parse-tool-uri.ts @@ -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 +} diff --git a/packages/validators/src/types.ts b/packages/validators/src/types.ts index ecb31c40..04af924e 100644 --- a/packages/validators/src/types.ts +++ b/packages/validators/src/types.ts @@ -1,4 +1,4 @@ -export type ParsedFaasIdentifier = { +export type ParsedToolIdentifier = { projectIdentifier: string deploymentHash?: string deploymentIdentifier?: string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f80b37b3..11b6e5e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/readme.md b/readme.md index 5521f8b9..601b9c88 100644 --- a/readme.md +++ b/readme.md @@ -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