Browse Source

chore: initial commit

e22m4u 2 weeks ago
commit
c5bb05693b

+ 9 - 0
.c8rc

@@ -0,0 +1,9 @@
+{
+  "all": true,
+  "include": [
+    "src/**/*.js"
+  ],
+  "exclude": [
+    "src/**/*.spec.js"
+  ]
+}

+ 5 - 0
.commitlintrc

@@ -0,0 +1,5 @@
+{
+  "extends": [
+    "@commitlint/config-conventional"
+  ]
+}

+ 13 - 0
.editorconfig

@@ -0,0 +1,13 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+insert_final_newline = true
+charset = utf-8
+indent_style = space
+indent_size = 2
+max_line_length = 80

+ 18 - 0
.gitignore

@@ -0,0 +1,18 @@
+# OS
+.DS_Store
+
+# Editor
+.idea
+.vscode
+
+# Npm
+node_modules
+npm-debug.log
+package-lock.json
+
+# Yarn
+.yarn/
+.yarn*
+
+# c8
+coverage

+ 1 - 0
.husky/commit-msg

@@ -0,0 +1 @@
+npx --no -- commitlint --edit $1

+ 6 - 0
.husky/pre-commit

@@ -0,0 +1,6 @@
+npm run lint:fix
+npm run format
+npm run test
+npm run build:cjs
+
+git add -A

+ 4 - 0
.mocharc.json

@@ -0,0 +1,4 @@
+{
+  "extension": ["js"],
+  "spec": "src/**/*.spec.js"
+}

+ 7 - 0
.prettierrc

@@ -0,0 +1,7 @@
+{
+  "bracketSpacing": false,
+  "singleQuote": true,
+  "printWidth": 80,
+  "trailingComma": "all",
+  "arrowParens": "avoid"
+}

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023-2025 Mikhail Evstropov <e22m4u@yandex.ru>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 34 - 0
README.md

@@ -0,0 +1,34 @@
+## @e22m4u/js-trie-router-data-mapper
+
+Парсинг и валидация данных для
+[@e22m4u/js-trie-router](https://www.npmjs.com/package/@e22m4u/js-trie-router).
+
+## Установка
+
+```bash
+npm install @e22m4u/js-trie-router-data-mapper
+```
+
+Модуль поддерживает ESM и CommonJS стандарты.
+
+*ESM*
+
+```js
+import {TrieRouterDataMapper} from '@e22m4u/js-trie-router-data-mapper';
+```
+
+*CommonJS*
+
+```js
+const {TrieRouterDataMapper} = require('@e22m4u/js-trie-router-data-mapper');
+```
+
+## Тесты
+
+```bash
+npm run test
+```
+
+## Лицензия
+
+MIT

+ 16 - 0
build-cjs.js

@@ -0,0 +1,16 @@
+import * as esbuild from 'esbuild';
+import packageJson from './package.json' with {type: 'json'};
+
+await esbuild.build({
+  entryPoints: ['src/index.js'],
+  outfile: 'dist/cjs/index.cjs',
+  format: 'cjs',
+  platform: 'node',
+  target: ['node12'],
+  bundle: true,
+  keepNames: true,
+  external: [
+    ...Object.keys(packageJson.peerDependencies || {}),
+    ...Object.keys(packageJson.dependencies || {}),
+  ],
+});

+ 40 - 0
eslint.config.js

@@ -0,0 +1,40 @@
+import globals from 'globals';
+import eslintJs from '@eslint/js';
+import eslintJsdocPlugin from 'eslint-plugin-jsdoc';
+import eslintMochaPlugin from 'eslint-plugin-mocha';
+import eslintImportPlugin from 'eslint-plugin-import';
+import eslintPrettierConfig from 'eslint-config-prettier';
+import eslintChaiExpectPlugin from 'eslint-plugin-chai-expect';
+
+export default [{
+  languageOptions: {
+    globals: {
+      ...globals.node,
+      ...globals.es2021,
+      ...globals.mocha,
+    },
+  },
+  plugins: {
+    'jsdoc': eslintJsdocPlugin,
+    'mocha': eslintMochaPlugin,
+    'import': eslintImportPlugin,
+    'chai-expect': eslintChaiExpectPlugin,
+  },
+  rules: {
+    ...eslintJs.configs.recommended.rules,
+    ...eslintPrettierConfig.rules,
+    ...eslintImportPlugin.flatConfigs.recommended.rules,
+    ...eslintMochaPlugin.configs.recommended.rules,
+    ...eslintChaiExpectPlugin.configs['recommended-flat'].rules,
+    ...eslintJsdocPlugin.configs['flat/recommended-error'].rules,
+    'no-duplicate-imports': 'error',
+    'import/export': 0,
+    'jsdoc/reject-any-type': 0,
+    'jsdoc/reject-function-type': 0,
+    'jsdoc/require-param-description': 0,
+    'jsdoc/require-returns-description': 0,
+    'jsdoc/require-property-description': 0,
+    'jsdoc/tag-lines': ['error', 'any', {startLines: 1}],
+  },
+  files: ['src/**/*.js'],
+}];

+ 52 - 0
examples/query-parsing-example.js

@@ -0,0 +1,52 @@
+import http from 'http';
+import {DataType} from '@e22m4u/js-data-schema';
+import {HttpMethod, TrieRouter} from '@e22m4u/js-trie-router';
+
+import {
+  HttpData,
+  TrieRouterDataMapper,
+} from '@e22m4u/js-trie-router-data-mapper';
+
+const router = new TrieRouter();
+router.useService(TrieRouterDataMapper);
+
+// регистрация маршрута для разбора
+// query параметра "filter"
+router.defineRoute({
+  method: HttpMethod.GET,
+  path: '/parseQuery',
+  meta: {
+    dataMap: {
+      filter: {
+        source: HttpData.REQUEST_QUERY,
+        property: 'filter',
+        schema: {
+          type: DataType.OBJECT,
+          required: true,
+        },
+      },
+    },
+  },
+  handler: ({state: {filter}}) => {
+    return filter;
+  },
+});
+
+// создание экземпляра HTTP сервера
+// и подключение обработчика запросов
+const server = new http.Server();
+server.on('request', router.requestListener);
+
+// прослушивание входящих запросов
+// на указанный адрес и порт
+const port = 3000;
+const host = '0.0.0.0';
+server.listen(port, host, function () {
+  const cyan = '\x1b[36m%s\x1b[0m';
+  console.log(cyan, 'Server listening on port:', port);
+  console.log(
+    cyan,
+    'Open in browser:',
+    `http://${host}:${port}/parseQuery?filter={"foo":"bar"}`,
+  );
+});

+ 48 - 0
examples/response-projection-example.js

@@ -0,0 +1,48 @@
+import http from 'http';
+import {HttpMethod, TrieRouter} from '@e22m4u/js-trie-router';
+
+import {
+  HttpData,
+  TrieRouterDataMapper,
+} from '@e22m4u/js-trie-router-data-mapper';
+
+const router = new TrieRouter();
+router.useService(TrieRouterDataMapper);
+
+// регистрация маршрута для проверки
+// проекции возвращаемого объекта
+router.defineRoute({
+  method: HttpMethod.GET,
+  path: '/responseProjection',
+  meta: {
+    dataMap: {
+      response: {
+        source: HttpData.RESPONSE_BODY,
+        schema: 'number',
+        projection: {foo: true, bar: false},
+      },
+    },
+  },
+  handler: () => {
+    return {foo: 10, bar: 20, baz: 30};
+  },
+});
+
+// создание экземпляра HTTP сервера
+// и подключение обработчика запросов
+const server = new http.Server();
+server.on('request', router.requestListener);
+
+// прослушивание входящих запросов
+// на указанный адрес и порт
+const port = 3000;
+const host = '0.0.0.0';
+server.listen(port, host, function () {
+  const cyan = '\x1b[36m%s\x1b[0m';
+  console.log(cyan, 'Server listening on port:', port);
+  console.log(
+    cyan,
+    'Open in browser:',
+    `http://${host}:${port}/responseProjection`,
+  );
+});

+ 71 - 0
package.json

@@ -0,0 +1,71 @@
+{
+  "name": "@e22m4u/js-trie-router-data-mapper",
+  "version": "0.0.0",
+  "description": "Парсинг и валидация данных для @e22m4u/js-trie-router",
+  "author": "Mikhail Evstropov <e22m4u@yandex.ru>",
+  "license": "MIT",
+  "keywords": [
+    "data",
+    "trie",
+    "router",
+    "parsing",
+    "validation"
+  ],
+  "homepage": "https://gitrepos.ru/e22m4u/js-trie-router-data-mapper",
+  "repository": {
+    "type": "git",
+    "url": "git+https://gitrepos.ru/e22m4u/js-trie-router-data-mapper.git"
+  },
+  "type": "module",
+  "types": "./src/index.d.ts",
+  "module": "./src/index.js",
+  "main": "./dist/cjs/index.cjs",
+  "exports": {
+    "types": "./src/index.d.ts",
+    "import": "./src/index.js",
+    "require": "./dist/cjs/index.cjs"
+  },
+  "engines": {
+    "node": ">=12"
+  },
+  "scripts": {
+    "lint": "tsc && eslint ./src",
+    "lint:fix": "tsc && eslint ./src --fix",
+    "format": "prettier --write \"./src/**/*.js\"",
+    "test": "npm run lint && c8 --reporter=text-summary mocha --bail",
+    "test:coverage": "npm run lint && c8 --reporter=text mocha --bail",
+    "build:cjs": "rimraf ./dist/cjs && node build-cjs.js",
+    "prepare": "husky"
+  },
+  "dependencies": {
+    "@e22m4u/js-data-projector": "~0.1.3",
+    "@e22m4u/js-data-schema": "~0.0.4",
+    "@e22m4u/js-format": "~0.3.2",
+    "@e22m4u/js-service": "~0.5.1"
+  },
+  "peerDependencies": {
+    "@e22m4u/js-trie-router": "~0.5.12"
+  },
+  "devDependencies": {
+    "@commitlint/cli": "~20.3.0",
+    "@commitlint/config-conventional": "~20.3.0",
+    "@eslint/js": "~9.39.2",
+    "@types/chai": "~5.2.3",
+    "@types/mocha": "~10.0.10",
+    "c8": "~10.1.3",
+    "chai": "~6.2.2",
+    "esbuild": "~0.27.2",
+    "eslint": "~9.39.2",
+    "eslint-config-prettier": "~10.1.8",
+    "eslint-plugin-chai-expect": "~3.1.0",
+    "eslint-plugin-import": "~2.32.0",
+    "eslint-plugin-jsdoc": "~61.5.0",
+    "eslint-plugin-mocha": "~11.2.0",
+    "globals": "~17.0.0",
+    "husky": "~9.1.7",
+    "mocha": "~11.7.5",
+    "prettier": "~3.7.4",
+    "rimraf": "~6.1.2",
+    "typescript": "~5.9.3"
+  }
+}

+ 41 - 0
src/data-mapping-schema.d.ts

@@ -0,0 +1,41 @@
+import {ProjectionSchema} from '@e22m4u/js-data-projector';
+import {DataSchema, DataType} from '@e22m4u/js-data-schema';
+
+/**
+ * Http data.
+ */
+export declare const HttpData: {
+  REQUEST_PARAMS: 'requestParams';
+  REQUEST_QUERY: 'requestQuery';
+  REQUEST_HEADERS: 'requestHeaders';
+  REQUEST_COOKIES: 'requestCookies';
+  REQUEST_BODY: 'requestBody';
+  RESPONSE_BODY: 'responseBody';
+};
+
+/**
+ * Http data.
+ */
+export type HttpData = (typeof HttpData)[keyof typeof HttpData];
+
+/**
+ * Http data list.
+ */
+export declare const HTTP_DATA_LIST: HttpData[];
+
+/**
+ * Data mapping schema.
+ */
+export type DataMappingSchema = {
+  [property: string]: DataMappingPropertyOptions | undefined;
+};
+
+/**
+ * Data mapping property options.
+ */
+export interface DataMappingPropertyOptions {
+  source: HttpData;
+  property?: string;
+  schema?: DataSchema | DataType;
+  projection?: ProjectionSchema;
+}

+ 16 - 0
src/data-mapping-schema.js

@@ -0,0 +1,16 @@
+/**
+ * Http data.
+ */
+export const HttpData = {
+  REQUEST_PARAMS: 'requestParams',
+  REQUEST_QUERY: 'requestQuery',
+  REQUEST_HEADERS: 'requestHeaders',
+  REQUEST_COOKIES: 'requestCookies',
+  REQUEST_BODY: 'requestBody',
+  RESPONSE_BODY: 'responseBody',
+};
+
+/**
+ * Http data list.
+ */
+export const HTTP_DATA_LIST = Object.values(HttpData);

+ 6 - 0
src/index.d.ts

@@ -0,0 +1,6 @@
+export * from './route-meta.js';
+export * from './data-mapping-schema.js';
+export * from './trie-router-data-mapper.js';
+export * from './validate-data-mapping-schema.js';
+export {ProjectionSchemaRegistry} from '@e22m4u/js-data-projector';
+export {DataType, DataSchemaRegistry} from '@e22m4u/js-data-schema';

+ 5 - 0
src/index.js

@@ -0,0 +1,5 @@
+export * from './data-mapping-schema.js';
+export * from './trie-router-data-mapper.js';
+export * from './validate-data-mapping-schema.js';
+export {ProjectionSchemaRegistry} from '@e22m4u/js-data-projector';
+export {DataType, DataSchemaRegistry} from '@e22m4u/js-data-schema';

+ 8 - 0
src/route-meta.d.ts

@@ -0,0 +1,8 @@
+import '@e22m4u/js-trie-router';
+import {DataMappingSchema} from './data-mapping-schema.js';
+
+declare module '@e22m4u/js-trie-router' {
+  export interface RouteMeta {
+    dataMap?: DataMappingSchema,
+  }
+}

+ 37 - 0
src/trie-router-data-mapper.d.ts

@@ -0,0 +1,37 @@
+import {RequestContext} from '@e22m4u/js-trie-router';
+import {DataMappingSchema} from './data-mapping-schema.js';
+import {Service, ServiceContainer} from '@e22m4u/js-service';
+
+/**
+ * Trie router data mapper.
+ */
+export declare class TrieRouterDataMapper extends Service {
+  /**
+   * Constructor.
+   *
+   * @param container
+   */
+  constructor(container?: ServiceContainer);
+
+  /**
+   * Create state by mapping schema.
+   *
+   * @param ctx
+   * @param schema
+   */
+  createStateByMappingSchema(
+    ctx: RequestContext,
+    schema: DataMappingSchema,
+  ): object;
+
+  /**
+   * Filter response by mapping schema.
+   *
+   * @param data
+   * @param schema
+   */
+  filterResponseByMappingSchema(
+    data: unknown,
+    schema: DataMappingSchema,
+  ): unknown;
+}

+ 205 - 0
src/trie-router-data-mapper.js

@@ -0,0 +1,205 @@
+import {Service} from '@e22m4u/js-service';
+import {HttpData} from './data-mapping-schema.js';
+import {InvalidArgumentError} from '@e22m4u/js-format';
+import {DataProjector} from '@e22m4u/js-data-projector';
+import {RequestContext, TrieRouter} from '@e22m4u/js-trie-router';
+import {DATA_TYPE_LIST, DataParser} from '@e22m4u/js-data-schema';
+import {validateDataMappingSchema} from './validate-data-mapping-schema.js';
+
+/**
+ * Константа HttpData определяет какое свойство контекста
+ * запроса будет использовано для формирования данных.
+ * Этот объект связывает значения HttpData со свойствами
+ * контекста запроса.
+ */
+const HTTP_DATA_TO_CONTEXT_PROPERTY_MAP = {
+  [HttpData.REQUEST_PARAMS]: 'params',
+  [HttpData.REQUEST_QUERY]: 'query',
+  [HttpData.REQUEST_HEADERS]: 'headers',
+  [HttpData.REQUEST_COOKIES]: 'cookies',
+  [HttpData.REQUEST_BODY]: 'body',
+};
+
+/**
+ * Trie router data mapper.
+ */
+export class TrieRouterDataMapper extends Service {
+  /**
+   * Constructor.
+   *
+   * @param {import('@e22m4u/js-service').ServiceContainer} container
+   */
+  constructor(container) {
+    super(container);
+    const router = this.getService(TrieRouter);
+    if (!router.hasPreHandler(dataMappingPreHandler)) {
+      router.addPreHandler(dataMappingPreHandler);
+    }
+    if (!router.hasPostHandler(dataMappingPostHandler)) {
+      router.addPostHandler(dataMappingPostHandler);
+    }
+  }
+
+  /**
+   * Create state by mapping schema.
+   *
+   * @param {import('@e22m4u/js-trie-router').RequestContext} ctx
+   * @param {import('./data-mapping-schema.js').DataMappingSchema} schema
+   * @returns {object}
+   */
+  createStateByMappingSchema(ctx, schema) {
+    if (!(ctx instanceof RequestContext)) {
+      throw new InvalidArgumentError(
+        'Parameter "ctx" must be a RequestContext instance, but %v was given.',
+        ctx,
+      );
+    }
+    validateDataMappingSchema(schema);
+    const res = {};
+    const dataParser = this.getService(DataParser);
+    const dataProjector = this.getService(DataProjector);
+    // обход каждого свойства схемы
+    // для формирования объекта данных
+    Object.keys(schema).forEach(propName => {
+      // если параметры свойства не определены,
+      // то данное свойство пропускается
+      const propOptions = schema[propName];
+      if (propOptions === undefined) {
+        return;
+      }
+      // если свойство контекста не определено,
+      // то данное свойство пропускается
+      const ctxProp = HTTP_DATA_TO_CONTEXT_PROPERTY_MAP[propOptions.source];
+      if (ctxProp === undefined) {
+        return;
+      }
+      let value = ctx[ctxProp];
+      // если определено вложенное свойство,
+      // то выполняется попытка его извлечения
+      if (propOptions.property && typeof propOptions.property === 'string') {
+        // если свойство контекста содержит объект,
+        // то извлекается значение вложенного свойства
+        if (value && typeof value === 'object' && !Array.isArray(value)) {
+          value = value[propOptions.property];
+        }
+        // если свойство контекста не является
+        // объектом, то выбрасывается ошибка
+        else {
+          throw new InvalidArgumentError(
+            'Property %v does not exist in %v value ' +
+              'from the property %v of the request context.',
+            propOptions.property,
+            value,
+            ctxProp,
+          );
+        }
+      }
+      // если определена схема данных,
+      // то выполняется разбор значения
+      if (propOptions.schema !== undefined) {
+        const sourcePath = propOptions.property
+          ? `request.${ctxProp}.${propOptions.property}`
+          : `request.${ctxProp}`;
+        if (DATA_TYPE_LIST.includes(propOptions.schema)) {
+          const dataSchema = {type: propOptions.schema};
+          value = dataParser.parse(value, dataSchema, {sourcePath});
+        } else {
+          value = dataParser.parse(value, propOptions.schema, {sourcePath});
+        }
+      }
+      // если определена схема проекции,
+      // то выполняется создание проекции
+      if (propOptions.projection !== undefined) {
+        value = dataProjector.projectInput(value, propOptions.projection);
+      }
+      // значение присваивается
+      // результирующему объекту
+      res[propName] = value;
+    });
+    return res;
+  }
+
+  /**
+   * Filter response by mapping schema.
+   *
+   * @param {*} data
+   * @param {import('./data-mapping-schema.js').DataMappingSchema} schema
+   * @returns {*}
+   */
+  filterResponseByMappingSchema(data, schema) {
+    validateDataMappingSchema(schema);
+    let res = data;
+    const dataParser = this.getService(DataParser);
+    const dataProjector = this.getService(DataProjector);
+    // обход каждого свойства схемы
+    // для формирования данных ответа
+    Object.keys(schema).forEach(propName => {
+      // если параметры свойства не определены,
+      // то данное свойство пропускается
+      const propOptions = schema[propName];
+      if (propOptions === undefined) {
+        return;
+      }
+      // если источником не является тело ответа,
+      // то данное свойство пропускается
+      if (propOptions.source !== HttpData.RESPONSE_BODY) {
+        return;
+      }
+      // если определено вложенное свойство,
+      // то выбрасывается ошибка
+      if (propOptions.property !== undefined) {
+        throw new InvalidArgumentError(
+          'Option "property" is not supported for the %v source, ' +
+            'but %v was given.',
+          propOptions.property,
+        );
+      }
+      // если определена схема данных,
+      // то выполняется разбор значения
+      if (propOptions.schema !== undefined) {
+        const sourcePath = 'response.body';
+        if (DATA_TYPE_LIST.includes(propOptions.schema)) {
+          const dataSchema = {type: propOptions.schema};
+          res = dataParser.parse(res, dataSchema, {sourcePath});
+        } else {
+          res = dataParser.parse(res, propOptions.schema, {sourcePath});
+        }
+      }
+      // если определена схема проекции,
+      // то выполняется создание проекции
+      if (propOptions.projection !== undefined) {
+        res = dataProjector.projectOutput(res, propOptions.projection);
+      }
+    });
+    return res;
+  }
+}
+
+/**
+ * Data mapping pre-handler.
+ *
+ * @type {import('@e22m4u/js-trie-router').PreHandlerHook}
+ */
+function dataMappingPreHandler(ctx) {
+  const schema = (ctx.meta || {}).dataMap;
+  if (schema === undefined) {
+    return;
+  }
+  const mapper = ctx.container.get(TrieRouterDataMapper);
+  const state = mapper.createStateByMappingSchema(ctx, schema);
+  ctx.state = {...ctx.state, ...state};
+}
+
+/**
+ * Data mapping post handler.
+ *
+ * @type {import('@e22m4u/js-trie-router').PostHandlerHook}
+ */
+function dataMappingPostHandler(ctx, data) {
+  const schema = (ctx.meta || {}).dataMap;
+  if (schema === undefined) {
+    return;
+  }
+  const mapper = ctx.container.get(TrieRouterDataMapper);
+  return mapper.filterResponseByMappingSchema(data, schema);
+}

+ 8 - 0
src/validate-data-mapping-schema.d.ts

@@ -0,0 +1,8 @@
+import {DataMappingSchema} from './data-mapping-schema.ts';
+
+/**
+ * Validate data mapping schema.
+ *
+ * @param schema
+ */
+export function validateDataMappingSchema(schema: DataMappingSchema): void;

+ 54 - 0
src/validate-data-mapping-schema.js

@@ -0,0 +1,54 @@
+import {InvalidArgumentError} from '@e22m4u/js-format';
+import {HTTP_DATA_LIST} from './data-mapping-schema.js';
+
+/**
+ * Validate data mapping schema.
+ *
+ * @param {import('./data-mapping-schema.js').DataMappingSchema} schema
+ */
+export function validateDataMappingSchema(schema) {
+  if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
+    throw new InvalidArgumentError(
+      'Mapping schema must be an Object, but %v was given.',
+      schema,
+    );
+  }
+  // schema[k]
+  Object.keys(schema).forEach(propName => {
+    const propOptions = schema[propName];
+    if (propOptions === undefined) {
+      return;
+    }
+    if (
+      !propOptions ||
+      typeof propOptions !== 'object' ||
+      Array.isArray(propOptions)
+    ) {
+      throw new InvalidArgumentError(
+        'Property options must be an Object, but %v was given.',
+        propOptions,
+      );
+    }
+    // schema[k].source
+    if (
+      !propOptions.source ||
+      typeof propOptions.source !== 'string' ||
+      !HTTP_DATA_LIST.includes(propOptions.source)
+    ) {
+      throw new InvalidArgumentError(
+        'Data source %v is not supported.',
+        propOptions.source,
+      );
+    }
+    // schema[k].property
+    if (
+      propOptions.property !== undefined &&
+      (!propOptions.property || typeof propOptions.property !== 'string')
+    ) {
+      throw new InvalidArgumentError(
+        'Property name must be a non-empty String, but %v was given.',
+        propOptions.property,
+      );
+    }
+  });
+}

+ 75 - 0
src/validate-data-mapping-schema.spec.js

@@ -0,0 +1,75 @@
+import {expect} from 'chai';
+import {format} from '@e22m4u/js-format';
+import {HttpData} from './data-mapping-schema.js';
+import {validateDataMappingSchema} from './validate-data-mapping-schema.js';
+
+describe('validateDataMappingSchema', function () {
+  it('should require the "schema" parameter to be an Object', function () {
+    const throwable = v => () => validateDataMappingSchema(v);
+    const error = s =>
+      format('Mapping schema must be an Object, but %s was given.', s);
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable(() => undefined)).to.throw(error('Function'));
+    throwable({})();
+  });
+
+  it('should require property options to be an Object', function () {
+    const throwable = v => () => validateDataMappingSchema({prop: v});
+    const error = s =>
+      format('Property options must be an Object, but %s was given.', s);
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable(() => undefined)).to.throw(error('Function'));
+    throwable({source: HttpData.REQUEST_BODY})();
+    throwable(undefined)();
+  });
+
+  it('should require the "source" option to be a HttpData value', function () {
+    const throwable = v => () => validateDataMappingSchema({prop: {source: v}});
+    const error = s => format('Data source %s is not supported.', s);
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable(() => undefined)).to.throw(error('Function'));
+    throwable(HttpData.REQUEST_BODY)();
+  });
+
+  it('should require the "property" option to be a non-empty String', function () {
+    const throwable = v => () =>
+      validateDataMappingSchema({
+        prop: {source: HttpData.REQUEST_BODY, property: v},
+      });
+    const error = s =>
+      format('Property name must be a non-empty String, but %s was given.', s);
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable(() => undefined)).to.throw(error('Function'));
+    throwable('str')();
+    throwable(undefined)();
+  });
+});

+ 14 - 0
tsconfig.json

@@ -0,0 +1,14 @@
+{
+  "compilerOptions": {
+    "strict": true,
+    "target": "es2022",
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext",
+    "noEmit": true,
+    "allowJs": true
+  },
+  "include": [
+    "./src/**/*.ts",
+    "./src/**/*.js"
+  ]
+}