From df69dc11b5c67f0fc18db7915f0066650d075b9e Mon Sep 17 00:00:00 2001 From: tada5hi Date: Mon, 21 Jul 2025 22:31:46 +0200 Subject: [PATCH] feat: initial parse hooks --- package-lock.json | 2 +- package.json | 1 + src/parser/base.ts | 1 + src/parser/parameters/fields/module.ts | 30 ++++++++------ src/parser/parameters/fields/types.ts | 3 +- src/schema/base.ts | 30 +++++++++++++- src/schema/module.ts | 2 +- src/schema/parameter/fields/constants.ts | 6 +++ src/schema/parameter/fields/schema.ts | 7 ++-- src/schema/parameter/fields/types.ts | 26 +++++++++--- src/schema/types.ts | 8 ---- test/unit/parser/fields-hook.spec.ts | 50 ++++++++++++++++++++++++ 12 files changed, 132 insertions(+), 34 deletions(-) create mode 100644 test/unit/parser/fields-hook.spec.ts diff --git a/package-lock.json b/package-lock.json index 11aed1567..530a5162a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@ucast/core": "^1.10.2", + "hookable": "^5.5.3", "smob": "^1.5.0" }, "devDependencies": { @@ -7669,7 +7670,6 @@ "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "dev": true, "license": "MIT" }, "node_modules/html-escaper": { diff --git a/package.json b/package.json index 888fe3e0d..220682751 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "homepage": "https://github.com/Tada5hi/rapiq#readme", "dependencies": { "@ucast/core": "^1.10.2", + "hookable": "^5.5.3", "smob": "^1.5.0" }, "devDependencies": { diff --git a/src/parser/base.ts b/src/parser/base.ts index 907e2b0e8..6e5187162 100644 --- a/src/parser/base.ts +++ b/src/parser/base.ts @@ -4,6 +4,7 @@ * For the full copyright and license information, * view the LICENSE file that was distributed with this source code. */ + import { DEFAULT_ID } from '../constants'; import type { Schema } from '../schema'; import { SchemaRegistry, defineSchema } from '../schema'; diff --git a/src/parser/parameters/fields/module.ts b/src/parser/parameters/fields/module.ts index cfd2dcd44..d1112a50f 100644 --- a/src/parser/parameters/fields/module.ts +++ b/src/parser/parameters/fields/module.ts @@ -6,18 +6,16 @@ */ import { isObject } from 'smob'; -import type { ObjectLiteral } from '../../../types'; -import { BaseParser } from '../../base'; +import { DEFAULT_ID } from '../../../constants'; import { - FieldOperator, FieldsSchema, Schema, defineFieldsSchema, + FieldOperator, FieldsHookName, FieldsSchema, Schema, defineFieldsSchema, } from '../../../schema'; -import { DEFAULT_ID } from '../../../constants'; +import { extractSubRelations } from '../../../schema/parameter/relations/helpers'; +import type { ObjectLiteral } from '../../../types'; +import { applyMapping, groupArrayByKeyPath, isPathAllowed } from '../../../utils'; +import { BaseParser } from '../../base'; import { FieldsParseError } from './error'; -import { - applyMapping, groupArrayByKeyPath, isPathAllowed, -} from '../../../utils'; import type { FieldsParseInputTransformed, FieldsParseOptions, FieldsParseOutput } from './types'; -import { extractSubRelations } from '../../../schema/parameter/relations/helpers'; export class FieldsParser extends BaseParser< FieldsParseOptions, @@ -45,6 +43,8 @@ FieldsParseOutput delete normalized[schema.name]; } + await schema.hooks.callHook(FieldsHookName.PARSE_NORMALIZED, normalized, options.context || {}); + const data = normalized[DEFAULT_ID] || []; delete normalized[DEFAULT_ID]; @@ -131,21 +131,25 @@ FieldsParseOutput } } - const keys = Object.keys(normalized); + await schema.hooks.callHook(FieldsHookName.PARSE_AFTER, output, options.context || {}); + + const normalizedKeys = Object.keys(normalized); if (options.relations) { + await schema.hooks.callHook(FieldsHookName.PARSE_RELATIONS, options.relations, options.context || {}); + for (let i = 0; i < options.relations.length; i++) { - const index = keys.indexOf(options.relations[i]); + const index = normalizedKeys.indexOf(options.relations[i]); if (index === -1) { - keys.push(options.relations[i]); + normalizedKeys.push(options.relations[i]); normalized[options.relations[i]] = []; } } } const grouped : Record> = {}; - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; + for (let i = 0; i < normalizedKeys.length; i++) { + const key = normalizedKeys[i]; let group : string; let relation : string; diff --git a/src/parser/parameters/fields/types.ts b/src/parser/parameters/fields/types.ts index 6436bc381..cba3d05fe 100644 --- a/src/parser/parameters/fields/types.ts +++ b/src/parser/parameters/fields/types.ts @@ -22,5 +22,6 @@ export type FieldsParseOptions< > = { relations?: RelationsParseOutput, schema?: string | Schema | FieldsSchema, - isChild?: boolean + isChild?: boolean, + context?: Record }; diff --git a/src/schema/base.ts b/src/schema/base.ts index d2d523d1c..5de46cafa 100644 --- a/src/schema/base.ts +++ b/src/schema/base.ts @@ -5,15 +5,43 @@ * view the LICENSE file that was distributed with this source code. */ +import type { HookCallback, HookKeys, Hookable } from 'hookable'; +import { createHooks } from 'hookable'; import type { BaseSchemaOptions } from './types'; -export class BaseSchema { +type HookInferCallback = HT[HN] extends HookCallback + ? HT[HN] + : never; + +export class BaseSchema< + OPTIONS extends BaseSchemaOptions = BaseSchemaOptions, + HOOKS extends Record = Record, +> { protected options: OPTIONS; + public readonly hooks: Hookable; + // --------------------------------------------------------- constructor(options: OPTIONS) { this.options = options; + this.hooks = createHooks(); + } + + // -------------------------------------------------- + + hook>( + name: NAME, + fn:HookInferCallback, + ) : CallableFunction { + return this.hooks.hook(name, fn); + } + + hookOnce>( + name: NAME, + fn:HookInferCallback, + ) : CallableFunction { + return this.hooks.hookOnce(name, fn); } // --------------------------------------------------------- diff --git a/src/schema/module.ts b/src/schema/module.ts index 878923701..baf7d460b 100644 --- a/src/schema/module.ts +++ b/src/schema/module.ts @@ -85,7 +85,7 @@ export class Schema< this.extendSchemaOptions(this.sort); } - private extendSchemaOptions(schema: BaseSchema) { + private extendSchemaOptions(schema: BaseSchema) { if ( typeof this.options.throwOnFailure !== 'undefined' && typeof schema.throwOnFailure === 'undefined' diff --git a/src/schema/parameter/fields/constants.ts b/src/schema/parameter/fields/constants.ts index fef41ef0b..9e32f0fce 100644 --- a/src/schema/parameter/fields/constants.ts +++ b/src/schema/parameter/fields/constants.ts @@ -9,3 +9,9 @@ export enum FieldOperator { INCLUDE = '+', EXCLUDE = '-', } + +export enum FieldsHookName { + PARSE_NORMALIZED = 'parse:normalized', + PARSE_AFTER = 'parse:after', + PARSE_RELATIONS = 'parse:relations', +} diff --git a/src/schema/parameter/fields/schema.ts b/src/schema/parameter/fields/schema.ts index f0986d635..087f69bab 100644 --- a/src/schema/parameter/fields/schema.ts +++ b/src/schema/parameter/fields/schema.ts @@ -9,13 +9,12 @@ import type { ObjectLiteral, SimpleKeys } from '../../../types'; import { isPropertyNameValid, } from '../../../utils'; -import type { FieldsOptions } from './types'; +import type { FieldsHooks, FieldsOptions } from './types'; import { BaseSchema } from '../../base'; export class FieldsSchema< RECORD extends ObjectLiteral = ObjectLiteral, - CONTEXT extends ObjectLiteral = ObjectLiteral, -> extends BaseSchema> { +> extends BaseSchema, FieldsHooks> { public default : string[]; public defaultIsUndefined : boolean; @@ -28,7 +27,7 @@ export class FieldsSchema< // --------------------------------------------------------- - constructor(input: FieldsOptions = {}) { + constructor(input: FieldsOptions = {}) { super(input); this.allowed = []; diff --git a/src/schema/parameter/fields/types.ts b/src/schema/parameter/fields/types.ts index 937615e8c..aa660959d 100644 --- a/src/schema/parameter/fields/types.ts +++ b/src/schema/parameter/fields/types.ts @@ -5,15 +5,31 @@ * view the LICENSE file that was distributed with this source code. */ -import type { BaseSchemaOptions, VerifyFn } from '../../types'; -import type { ObjectLiteral, SimpleKeys } from '../../../types'; +import type { BaseSchemaOptions } from '../../types'; +import type { ObjectLiteral, ObjectLiteralKeys, SimpleKeys } from '../../../types'; +import type { FieldsHookName } from './constants'; export type FieldsOptions< T extends Record = Record, - CONTEXT extends ObjectLiteral = ObjectLiteral, > = BaseSchemaOptions & { mapping?: Record, allowed?: SimpleKeys[], - default?: SimpleKeys[], - verify?: VerifyFn + default?: SimpleKeys[] }; + +export type FieldsHooks = ObjectLiteralKeys<{ + [FieldsHookName.PARSE_NORMALIZED]: ( + value: Record, + context: ObjectLiteral + ) => Promise | void; + + [FieldsHookName.PARSE_AFTER]: ( + value: string[], + context: ObjectLiteral + ) => Promise | void, + + [FieldsHookName.PARSE_RELATIONS]: ( + value: string[], + context: ObjectLiteral + ) => Promise | void, +}>; diff --git a/src/schema/types.ts b/src/schema/types.ts index 8725c57d6..74a54571d 100644 --- a/src/schema/types.ts +++ b/src/schema/types.ts @@ -37,14 +37,6 @@ export type BaseSchemaOptions = { schemaMapping?: Record }; -export type VerifyFn< - VALUE = unknown, - CONTEXT extends Record = Record, -> = ( - value: VALUE, - context: CONTEXT -) => Promise; - export type SchemaOptionsNormalized< RECORD extends ObjectLiteral = ObjectLiteral, > = BaseSchemaOptions & { diff --git a/test/unit/parser/fields-hook.spec.ts b/test/unit/parser/fields-hook.spec.ts new file mode 100644 index 000000000..b51acaf43 --- /dev/null +++ b/test/unit/parser/fields-hook.spec.ts @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { FieldsParser, defineSchema } from '../../../src'; +import { registry } from '../../data/schema'; + +describe('parser/fields/hooks', () => { + let parser: FieldsParser; + + beforeAll(() => { + parser = new FieldsParser(registry); + }); + + it('should work with hook: parse:normalized', async () => { + const schema = defineSchema({}); + schema.fields.hook('parse:normalized', () => {}); + + const output = await parser.parse(['foo'], { schema }); + expect(output).toEqual(['foo']); + + expect(schema.fields.hooks._hooks['parse:normalized']).toBeDefined(); + }); + + it('should work with parse:after hook', async () => { + const schema = defineSchema({}); + schema.fields.hook('parse:after', (data) => { + data.push('bar'); + }); + + const output = await parser.parse(['foo'], { schema }); + expect(output).toEqual(['foo', 'bar']); + }); + + it('should work with parse:relations hook', async () => { + const schema = registry.getOrFail('user'); + schema.fields.hook('parse:relations', (data) => { + data.push('realm'); + }); + + const output = await parser.parse(['id', 'realm.name'], { schema, relations: [] }); + expect(output).toEqual([ + 'id', + 'realm.name', + ]); + }); +});