e22m4u 6 дней назад
Сommit
a7e2c4cab8

+ 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.

+ 326 - 0
README.md

@@ -0,0 +1,326 @@
+## @e22m4u/js-openapi
+
+JavaScript модуль для создания
+[OpenAPI Документа 3.1.0](https://spec.openapis.org/oas/v3.1.0)
+
+## Содержание
+
+- [Установка](#установка)
+- [Базовый пример](#базовый-пример)
+- [Работа с компонентами](#работа-с-компонентами)
+- [Содержимое запросов и ответов](#содержимое-запросов-и-ответов)
+- [Группировка маршрутов](#группировка-маршрутов)
+- [Тесты](#тесты)
+- [Лицензия](#лицензия)
+
+## Установка
+
+```bash
+npm install @e22m4u/js-openapi
+```
+
+Модуль поддерживает ESM и CommonJS стандарты.
+
+*ESM*
+
+```js
+import {OADocumentBuilder} from '@e22m4u/js-openapi';
+```
+
+*CommonJS*
+
+```js
+const {OADocumentBuilder} = require('@e22m4u/js-openapi');
+```
+
+## Базовый пример
+
+Создание экземпляра сборщика.
+
+```js
+import {OADocumentBuilder} from '@e22m4u/js-openapi';
+
+const builder = new OADocumentBuilder({
+  info: {
+    title: 'My Simple API',
+    version: '0.0.1',
+  },
+});
+```
+
+Определение операции.
+
+```js
+import {OAOperationMethod} from '@e22m4u/js-openapi';
+
+builder.defineOperation({
+  path: '/status',
+  method: OAOperationMethod.GET,
+  operation: {
+    summary: 'Get server status',
+    responses: {
+      200: {
+        description: 'Server works fine',
+      },
+    },
+  },
+});
+```
+
+Сборка документа.
+
+```js
+const document = builder.build();
+
+console.log(JSON.stringify(document, null, 2));
+// {
+//   "openapi": "3.1.0",
+//   "info": {
+//     "title": "My Simple API",
+//     "version": "0.0.1"
+//   },
+//   "paths": {
+//     "/status": {
+//       "get": {
+//         "summary": "Get server status",
+//         "responses": {
+//           "200": {
+//             "description": "Server works fine"
+//           }
+//         }
+//       }
+//     }
+//   }
+// }
+```
+
+## Работа с компонентами
+
+Регистрация компонента схемы.
+
+```js
+import {OADataType, OAOperationMethod} from '@e22m4u/js-openapi';
+
+builder.defineSchemaComponent({
+  name: 'User',
+  schema: {
+    type: OADataType.OBJECT,
+    properties: {
+      id: {
+        type: OADataType.STRING,
+        format: 'uuid',
+      },
+      email: {
+        type: OADataType.STRING,
+        format: 'email',
+      },
+    },
+    required: ['id', 'email'],
+  },
+});
+```
+
+Использование зарегистрированного имени схемы.
+
+```js
+import {oaRef, OAOperationMethod} from '@e22m4u/js-openapi';
+
+builder.defineOperation({
+  path: '/users/{id}',
+  method: OAOperationMethod.GET,
+  operation: {
+    responses: {
+      200: {
+        description: 'User found',
+        content: {
+          'application/json': {
+            schema: oaRef('User'),
+            // утилита "oaRef" создаст объект
+            // {"$ref": "#/components/schemas/User"}
+          },
+        },
+      },
+    },
+  },
+});
+```
+
+## Содержимое запросов и ответов
+
+Определение тела ответа.
+
+```js
+import {
+  OADataType,
+  oaJsonContent,
+  OAOperationMethod,
+} from '@e22m4u/js-openapi';
+
+builder.defineOperation({
+  path: '/status',
+  method: OAOperationMethod.GET,
+  operation: {
+    summary: 'Get server status',
+    responses: {
+      200: {
+        description: 'Server works fine',
+        content: oaJsonContent({
+          // утилита "oaJsonContent" оборачивает схему
+          // в стандартную структуру "application/json"
+          type: OADataType.OBJECT,
+          properties: {
+            status: {
+              type: OADataType.STRING,
+              example: 'ok',
+            },
+          },
+        }),
+      },
+    },
+  },
+});
+```
+
+Определение компонента схемы для использования в следующем примере.
+
+```js
+import {OADataType} from '@e22m4u/js-openapi';
+
+builder.defineSchemaComponent({
+  name: 'UserInput',
+  schema: {
+    type: OADataType.OBJECT,
+    properties: {
+      email: {
+        type: OADataType.STRING,
+        format: 'email',
+      },
+      password: {
+        type: OADataType.STRING,
+      },
+    },
+    required: ['email', 'password'],
+  },
+});
+```
+
+Определение тела запроса и ответа с использованием ссылок.
+
+```js
+import {oaRef, oaJsonContent, OAOperationMethod} from '@e22m4u/js-openapi';
+
+builder.defineOperation({
+  path: '/users',
+  method: OAOperationMethod.POST,
+  operation: {
+    summary: 'Create a new user',
+    requestBody: {
+      description: 'Data for the new user',
+      required: true,
+      content: oaJsonContent(oaRef('UserInput')),
+      // "content": {
+      //   "application/json": {
+      //     "schema": {
+      //       "$ref": "#/components/schemas/UserInput"
+      //     }
+      //   }
+      // }
+    },
+    responses: {
+      201: {
+        description: 'User created',
+        content: oaJsonContent(oaRef('User')),
+        // "content": {
+        //   "application/json": {
+        //     "schema": {
+        //       "$ref": "#/components/schemas/User"
+        //     }
+        //   }
+        // }
+      },
+    },
+  },
+});
+```
+
+## Группировка маршрутов
+
+Создание области с общим префиксом пути и тегом.
+
+```js
+import {OAOperationMethod} from '@e22m4u/js-openapi';
+
+// создание области /users
+const usersScope = builder.createScope({
+  pathPrefix: '/users',
+  tags: ['User'], // опционально
+});
+
+// маршрут GET /users/{id}
+usersScope.defineOperation({
+  path: '/{id}',
+  method: OAOperationMethod.GET,
+  operation: {
+    summary: 'Get user by id',
+    responses: {
+      200: {
+        description: 'User found',
+      },
+    },
+  },
+});
+
+// маршрут DELETE /users/{id}
+usersScope.defineOperation({
+  path: '/{id}',
+  method: OAOperationMethod.DELETE,
+  operation: {
+    summary: 'Delete user by id',
+    responses: {
+      200: {
+        description: 'User deleted',
+      },
+    },
+  },
+});
+```
+
+Создание вложенных областей для комбинирования маршрутов.
+
+```js
+import {OAOperationMethod} from '@e22m4u/js-openapi';
+
+// область "/api/v1"
+const v1Scope = builder.createScope({
+  pathPrefix: '/api/v1',
+});
+
+// область "/api/v1/admin"
+const adminScope = v1Scope.createScope({
+  pathPrefix: '/admin',
+});
+
+// DELETE /api/v1/admin/users/{id}
+adminScope.defineOperation({
+  path: '/users/{id}',
+  method: OAOperationMethod.DELETE,
+  operation: {
+    summary: 'Delete user by id',
+    responses: {
+      200: {
+        description: 'User deleted',
+      },
+    },
+  },
+});
+```
+
+## Тесты
+
+```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 || {}),
+  ],
+});

+ 41 - 0
eslint.config.js

@@ -0,0 +1,41 @@
+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,
+    "curly": "error",
+    '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'],
+}];

+ 64 - 0
package.json

@@ -0,0 +1,64 @@
+{
+  "name": "@e22m4u/js-openapi",
+  "version": "0.0.1",
+  "description": "JavaScript модуль для создания OpenAPI Документа",
+  "author": "Mikhail Evstropov <e22m4u@yandex.ru>",
+  "license": "MIT",
+  "keywords": [
+    "schema",
+    "openapi",
+    "generator"
+  ],
+  "homepage": "https://gitrepos.ru/e22m4u/js-openapi",
+  "repository": {
+    "type": "git",
+    "url": "git+https://gitrepos.ru/e22m4u/js-openapi.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-format": "~0.3.2",
+    "@e22m4u/js-service": "~0.5.1"
+  },
+  "devDependencies": {
+    "@commitlint/cli": "~20.3.1",
+    "@commitlint/config-conventional": "~20.3.1",
+    "@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": "~62.0.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"
+  }
+}

+ 4 - 0
src/constants.d.ts

@@ -0,0 +1,4 @@
+/**
+ * OpenAPI version.
+ */
+export declare const OPENAPI_VERSION: '3.1.0';

+ 4 - 0
src/constants.js

@@ -0,0 +1,4 @@
+/**
+ * OpenAPI version.
+ */
+export const OPENAPI_VERSION = '3.1.0';

+ 567 - 0
src/document-specification.d.ts

@@ -0,0 +1,567 @@
+// OpenApi version 3.1.0
+// https://spec.openapis.org/oas/v3.1.0
+
+/**
+ * Document object.
+ * https://spec.openapis.org/oas/v3.1.0#openapi-object
+ */
+export type OADocumentObject = {
+  openapi: string;
+  info: OAInfoObject;
+  jsonSchemaDialect?: string;
+  servers?: OAServerObject[];
+  paths?: OAPathsObject;
+  webhooks?: OAWebhooksObject;
+  components?: OAComponentsObject;
+  security?: OASecurityRequirementObject[];
+  tags?: OATagObject[];
+  externalDocs?: OAExternalDocumentationObject;
+};
+
+/**
+ * Info Object.
+ * https://spec.openapis.org/oas/v3.1.0#info-object
+ */
+export type OAInfoObject = {
+  title: string;
+  summary?: string;
+  description?: string;
+  termsOfService?: string;
+  contact?: OAContactObject;
+  license?: OALicenseObject;
+  version: string;
+};
+
+/**
+ * Contact Object.
+ * https://spec.openapis.org/oas/v3.1.0#contact-object
+ */
+export type OAContactObject = {
+  name?: string;
+  url?: string;
+  email?: string;
+};
+
+/**
+ * License Object.
+ * https://spec.openapis.org/oas/v3.1.0#license-object
+ */
+export type OALicenseObject = {
+  name: string;
+  identifier?: string;
+  url?: string;
+};
+
+/**
+ * Server Object.
+ * https://spec.openapis.org/oas/v3.1.0#server-object
+ */
+export type OAServerObject = {
+  url: string;
+  description?: string;
+  variables?: OAServerVariablesObject;
+};
+
+/**
+ * Server variable object.
+ * https://spec.openapis.org/oas/v3.1.0#server-variable-object
+ */
+export type OAServerVariableObject = {
+  enum?: string[];
+  default: string;
+  description?: string;
+};
+
+/**
+ * Paths Object.
+ * https://spec.openapis.org/oas/v3.1.0#paths-object
+ */
+export type OAPathsObject = {
+  [path: string]: OAPathItemObject | undefined;
+};
+
+/**
+ * Path Item Object
+ * https://spec.openapis.org/oas/v3.1.0#path-item-object
+ */
+export type OAPathItemObject = {
+  $ref?: string;
+  summary?: string;
+  description?: string;
+  get?: OAOperationObject;
+  put?: OAOperationObject;
+  post?: OAOperationObject;
+  delete?: OAOperationObject;
+  options?: OAOperationObject;
+  head?: OAOperationObject;
+  patch?: OAOperationObject;
+  trace?: OAOperationObject;
+  servers?: OAServerObject[];
+  parameters?: (OAParameterObject | OAReferenceObject)[];
+};
+
+/**
+ * Operation Method.
+ * https://spec.openapis.org/oas/v3.1.0#path-item-object
+ */
+export declare const OAOperationMethod: {
+  GET: 'get';
+  PUT: 'put';
+  POST: 'post';
+  DELETE: 'delete';
+  OPTIONS: 'options';
+  HEAD: 'head';
+  PATCH: 'patch';
+  TRACE: 'trace';
+};
+
+export type OAOperationMethod =
+  (typeof OAOperationMethod)[keyof typeof OAOperationMethod];
+
+/**
+ * Parameter Object.
+ * https://spec.openapis.org/oas/v3.1.0#parameter-object
+ */
+export type OAParameterObject = {
+  name: string;
+  in: OAParameterLocation;
+  description?: string;
+  required?: boolean;
+  deprecated?: boolean;
+  allowEmptyValue?: boolean;
+  style?: OAParameterStyle;
+  explode?: boolean;
+  allowReserved?: boolean;
+  schema?: OASchemaObject;
+  content?: OAContentObject;
+};
+
+/**
+ * Parameter Location.
+ * https://spec.openapis.org/oas/v3.1.0#parameter-locations
+ */
+export declare const OAParameterLocation: {
+  QUERY: 'query';
+  HEADER: 'header';
+  PATH: 'path';
+  COOKIE: 'cookie';
+};
+
+export type OAParameterLocation =
+  (typeof OAParameterLocation)[keyof typeof OAParameterLocation];
+
+/**
+ * Parameter Style.
+ * https://spec.openapis.org/oas/v3.1.0#style-values
+ */
+export declare const OAParameterStyle: {
+  MATRIX: 'matrix';
+  LABEL: 'label';
+  FORM: 'form';
+  SIMPLE: 'simple';
+  SPACE_DELIMITED: 'spaceDelimited';
+  PIPE_DELIMITED: 'pipeDelimited';
+  DEEP_OBJECT: 'deepObject';
+};
+
+export type OAParameterStyle =
+  (typeof OAParameterStyle)[keyof typeof OAParameterStyle];
+
+/**
+ * Reference Object.
+ * https://spec.openapis.org/oas/v3.1.0#reference-object
+ */
+export type OAReferenceObject = {
+  $ref: string;
+  summary?: string;
+  description?: string;
+};
+
+/**
+ * Schema Object.
+ * https://spec.openapis.org/oas/v3.1.0#schema-object
+ */
+export type OASchemaObject = {
+  type?: OADataType | OADataType[];
+  format?: OADataFormat | string;
+  items?: OASchemaObject | OAReferenceObject;
+  required?: string[];
+  minimum?: number;
+  maximum?: number;
+  default?: unknown;
+  properties?: OASchemaPropertiesObject;
+  discriminator?: OADiscriminatorObject;
+  xml?: OAXmlObject;
+  externalDocs?: OAExternalDocumentationObject;
+};
+
+/**
+ * Data type.
+ * https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00#section-4.2.1
+ */
+export declare const OADataType: {
+  STRING: 'string';
+  NUMBER: 'number';
+  INTEGER: 'integer';
+  BOOLEAN: 'boolean';
+  OBJECT: 'object';
+  ARRAY: 'array';
+  NULL: 'null';
+};
+
+export type OADataType = (typeof OADataType)[keyof typeof OADataType];
+
+/**
+ * Data format.
+ * https://spec.openapis.org/oas/v3.1.0#dataTypeFormat
+ */
+export declare const OADataFormat: {
+  INT32: 'int32';
+  INT64: 'int64';
+  FLOAT: 'float';
+  DOUBLE: 'double';
+  PASSWORD: 'password';
+  BINARY: 'binary';
+};
+
+export type OADataFormat = (typeof OADataFormat)[keyof typeof OADataFormat];
+
+/**
+ * Discriminator Object.
+ * https://spec.openapis.org/oas/v3.1.0#discriminator-object
+ */
+export type OADiscriminatorObject = {
+  propertyName: string;
+  mapping?: {[name: string]: string | undefined};
+};
+
+/**
+ * Xml Object.
+ * https://spec.openapis.org/oas/v3.1.0#xml-object
+ */
+export type OAXmlObject = {
+  name?: string;
+  namespace?: string;
+  prefix?: string;
+  attribute?: boolean;
+  wrapped?: boolean;
+};
+
+/**
+ * External Documentation Object.
+ * https://spec.openapis.org/oas/v3.1.0#external-documentation-object
+ */
+export type OAExternalDocumentationObject = {
+  description?: string;
+  url: string;
+};
+
+/**
+ * Operation Object.
+ * https://spec.openapis.org/oas/v3.1.0#operation-object
+ */
+export type OAOperationObject = {
+  tags?: string[];
+  summary?: string;
+  description?: string;
+  externalDocs?: OAExternalDocumentationObject;
+  operationId?: string;
+  parameters?: (OAParameterObject | OAReferenceObject)[];
+  requestBody?: OARequestBodyObject | OAReferenceObject;
+  responses?: OAResponsesObject;
+  callbacks?: OACallbacksObject;
+};
+
+/**
+ * Callback Object.
+ * https://spec.openapis.org/oas/v3.1.0#callback-object
+ */
+export type OACallbackObject = {
+  [expression: string]: OAPathItemObject | OAReferenceObject | undefined;
+};
+
+/**
+ * Request Body Object.
+ * https://spec.openapis.org/oas/v3.1.0#request-body-object
+ */
+export type OARequestBodyObject = {
+  description?: string;
+  content: OAContentObject;
+  required?: boolean;
+};
+
+/**
+ * Media type.
+ * https://spec.openapis.org/oas/v3.1.0#media-types
+ */
+export declare const OAMediaType: {
+  TEXT_PLAIN: 'text/plain';
+  TEXT_HTML: 'text/html';
+  APPLICATION_XML: 'application/xml';
+  APPLICATION_JSON: 'application/json';
+  MULTIPART_FORM_DATA: 'multipart/form-data';
+};
+
+export type OAMediaType = (typeof OAMediaType)[keyof typeof OAMediaType];
+
+/**
+ * Responses Object.
+ * https://spec.openapis.org/oas/v3.1.0#responses-object
+ */
+export type OAResponsesObject = {
+  [httpStatusCode: string]: OAResponseObject | OAReferenceObject | undefined;
+};
+
+/**
+ * Response Object.
+ * https://spec.openapis.org/oas/v3.1.0#response-object
+ */
+export type OAResponseObject = {
+  description: string;
+  headers?: OAHeadersObject;
+  content?: OAContentObject;
+  links?: OALinksObject;
+};
+
+/**
+ * Media Type Object.
+ * https://spec.openapis.org/oas/v3.1.0#media-type-object
+ */
+export type OAMediaTypeObject = {
+  schema?: OASchemaObject;
+  example?: unknown;
+  examples?: OAExamplesObject;
+  encoding?: OAPropertiesEncodingObject;
+};
+
+/**
+ * Example Object.
+ * https://spec.openapis.org/oas/v3.1.0#example-object
+ */
+export type OAExampleObject = {
+  summary?: string;
+  description?: string;
+  value?: unknown;
+  externalValue?: string;
+};
+
+/**
+ * Encoding Object.
+ * https://spec.openapis.org/oas/v3.1.0#encoding-object
+ */
+export type OAEncodingObject = {
+  contentType?: string;
+  headers?: OAHeadersObject;
+  style?: OAParameterStyle;
+  explode?: boolean;
+  allowReserved?: boolean;
+};
+
+/**
+ * Header Object.
+ * https://spec.openapis.org/oas/v3.1.0#header-object
+ */
+export type OAHeaderObject = Omit<OAParameterObject, 'name' | 'in'>;
+
+/**
+ * Link Object.
+ * https://spec.openapis.org/oas/v3.1.0#link-object
+ */
+export type OALinkObject = {
+  operationRef?: string;
+  operationId?: string;
+  parameters?: {[name: string]: unknown | undefined};
+  requestBody?: unknown;
+  description?: string;
+  server?: OAServerObject;
+};
+
+/**
+ * Components Object.
+ * https://spec.openapis.org/oas/v3.1.0#components-object
+ */
+export type OAComponentsObject = {
+  schemas?: {[name: string]: OASchemaObject | undefined};
+  responses?: {
+    [name: string]: OAResponseObject | OAReferenceObject | undefined;
+  };
+  parameters?: {
+    [name: string]: OAParameterObject | OAReferenceObject | undefined;
+  };
+  examples?: {
+    [name: string]: OAExampleObject | OAReferenceObject | undefined;
+  };
+  requestBodies?: {
+    [name: string]: OARequestBodyObject | OAReferenceObject | undefined;
+  };
+  headers?: {
+    [name: string]: OAHeaderObject | OAReferenceObject | undefined;
+  };
+  securitySchemes?: {
+    [name: string]: OASecuritySchemeObject | OAReferenceObject | undefined;
+  };
+  links?: {
+    [name: string]: OALinkObject | OAReferenceObject | undefined;
+  };
+  callbacks?: {
+    [name: string]: OACallbackObject | OAReferenceObject | undefined;
+  };
+  pathItems?: {
+    [name: string]: OAPathItemObject | OAReferenceObject | undefined;
+  };
+};
+
+/**
+ * Security Scheme Object.
+ * https://spec.openapis.org/oas/v3.1.0#security-scheme-object
+ */
+export type OASecuritySchemeObject = {
+  type: OASecuritySchemeType;
+  description?: string;
+  name?: string;
+  in?: OAApiKeyLocation;
+  scheme?: string;
+  bearerFormat?: string;
+  flows?: OAOAuthFlowsObject;
+  openIdConnectUrl?: string;
+};
+
+/**
+ * Security Scheme Type.
+ * https://spec.openapis.org/oas/v3.1.0#security-scheme-object
+ */
+export declare const OASecuritySchemeType: {
+  API_KEY: 'apiKey';
+  HTTP: 'http';
+  MUTUAL_TLS: 'mutualTLS';
+  OAUTH_2: 'oauth2';
+  OPEN_ID_CONNECT: 'openIdConnect';
+};
+
+export type OASecuritySchemeType =
+  (typeof OASecuritySchemeType)[keyof typeof OASecuritySchemeType];
+
+/**
+ * Api Key Location.
+ * https://spec.openapis.org/oas/v3.1.0#security-scheme-object
+ */
+export declare const OAApiKeyLocation: {
+  QUERY: 'query';
+  HEADER: 'header';
+  COOKIE: 'cookie';
+};
+
+export type OAApiKeyLocation =
+  (typeof OAApiKeyLocation)[keyof typeof OAApiKeyLocation];
+
+/**
+ * OAuth Flows Object.
+ * https://spec.openapis.org/oas/v3.1.0#oauth-flows-object
+ */
+export type OAOAuthFlowsObject = {
+  implicit?: OAOAuthFlowObject;
+  password?: OAOAuthFlowObject;
+  clientCredentials?: OAOAuthFlowObject;
+  authorizationCode?: OAOAuthFlowObject;
+};
+
+/**
+ * OAuth Flow Object.
+ * https://spec.openapis.org/oas/v3.1.0#oauth-flow-object
+ */
+export type OAOAuthFlowObject = {
+  authorizationUrl: string;
+  tokenUrl: string;
+  refreshUrl?: string;
+  scopes: {[name: string]: string | undefined};
+};
+
+/**
+ * Security Requirement Object.
+ * https://spec.openapis.org/oas/v3.1.0#security-requirement-object
+ */
+export type OASecurityRequirementObject = {
+  [name: string]: string[] | undefined;
+};
+
+/**
+ * Tag object.
+ * https://spec.openapis.org/oas/v3.1.0#tag-object
+ */
+export type OATagObject = {
+  name: string;
+  description?: string;
+  externalDocs?: OAExternalDocumentationObject;
+};
+
+/**
+ * Server Variables Object.
+ * (non-spec type)
+ */
+export type OAServerVariablesObject = {
+  [name: string]: OAServerVariableObject | undefined;
+};
+
+/**
+ * Schema Properties Object.
+ * (non-spec type)
+ */
+export type OASchemaPropertiesObject = {
+  [name: string]: OASchemaObject | OAReferenceObject | undefined;
+};
+
+/**
+ * Webhooks Object.
+ * (non-spec type)
+ */
+export type OAWebhooksObject = {
+  [name: string]: OAPathItemObject | OAReferenceObject | undefined;
+}
+
+/**
+ * Callbacks Object.
+ * (non-spec type)
+ */
+export type OACallbacksObject = {
+  [key: string]: OACallbackObject | OAReferenceObject | undefined;
+};
+
+/**
+ * Content Object.
+ * (non-spec type)
+ */
+export type OAContentObject = {
+  [mediaType: string]: OAMediaTypeObject | undefined;
+};
+
+/**
+ * Headers Object.
+ * (non-spec type)
+ */
+export type OAHeadersObject = {
+  [name: string]: OAHeaderObject | OAReferenceObject | undefined;
+};
+
+/**
+ * Links Object.
+ * (non-spec type)
+ */
+export type OALinksObject = {
+  [name: string]: OALinkObject | OAReferenceObject | undefined;
+};
+
+/**
+ * Examples Object.
+ * (non-spec type)
+ */
+export type OAExamplesObject = {
+  [name: string]: OAExampleObject | OAReferenceObject | undefined;
+};
+
+/**
+ * Properties Encoding Object.
+ * (non-spec type)
+ */
+export type OAPropertiesEncodingObject = {
+  [propertyName: string]: OAEncodingObject | undefined;
+};

+ 103 - 0
src/document-specification.js

@@ -0,0 +1,103 @@
+// OpenApi version 3.1.0
+// https://spec.openapis.org/oas/v3.1.0
+
+/**
+ * Operation Method.
+ * https://spec.openapis.org/oas/v3.1.0#path-item-object
+ */
+export const OAOperationMethod = {
+  GET: 'get',
+  PUT: 'put',
+  POST: 'post',
+  DELETE: 'delete',
+  OPTIONS: 'options',
+  HEAD: 'head',
+  PATCH: 'patch',
+  TRACE: 'trace',
+}
+
+/**
+ * Parameter Location.
+ * https://spec.openapis.org/oas/v3.1.0#parameter-locations
+ */
+export const OAParameterLocation = {
+  QUERY: 'query',
+  HEADER: 'header',
+  PATH: 'path',
+  COOKIE: 'cookie',
+}
+
+/**
+ * Parameter Style.
+ * https://spec.openapis.org/oas/v3.1.0#style-values
+ */
+export const OAParameterStyle = {
+  MATRIX: 'matrix',
+  LABEL: 'label',
+  FORM: 'form',
+  SIMPLE: 'simple',
+  SPACE_DELIMITED: 'spaceDelimited',
+  PIPE_DELIMITED: 'pipeDelimited',
+  DEEP_OBJECT: 'deepObject',
+};
+
+/**
+ * Data type.
+ * https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00#section-4.2.1
+ */
+export const OADataType = {
+  STRING: 'string',
+  NUMBER: 'number',
+  INTEGER: 'integer',
+  BOOLEAN: 'boolean',
+  OBJECT: 'object',
+  ARRAY: 'array',
+  NULL: 'null',
+}
+
+/**
+ * Data format.
+ * https://spec.openapis.org/oas/v3.1.0#dataTypeFormat
+ */
+export const OADataFormat = {
+  INT32: 'int32',
+  INT64: 'int64',
+  FLOAT: 'float',
+  DOUBLE: 'double',
+  PASSWORD: 'password',
+  BINARY: 'binary',
+}
+
+/**
+ * Media type.
+ * https://spec.openapis.org/oas/v3.1.0#media-types
+ */
+export const OAMediaType = {
+  TEXT_PLAIN: 'text/plain',
+  TEXT_HTML: 'text/html',
+  APPLICATION_XML: 'application/xml',
+  APPLICATION_JSON: 'application/json',
+  MULTIPART_FORM_DATA: 'multipart/form-data',
+}
+
+/**
+ * Security Scheme Type.
+ * https://spec.openapis.org/oas/v3.1.0#security-scheme-object
+ */
+export const OASecuritySchemeType = {
+  API_KEY: 'apiKey',
+  HTTP: 'http',
+  MUTUAL_TLS: 'mutualTLS',
+  OAUTH_2: 'oauth2',
+  OPEN_ID_CONNECT: 'openIdConnect',
+}
+
+/**
+ * Api Key Location.
+ * https://spec.openapis.org/oas/v3.1.0#security-scheme-object
+ */
+export const OAApiKeyLocation = {
+  QUERY: 'query',
+  HEADER: 'header',
+  COOKIE: 'cookie',
+}

+ 10 - 0
src/document-validators.d.ts

@@ -0,0 +1,10 @@
+import {OADocumentObject} from './document-specification.js';
+
+/**
+ * Validate shallow OADocumentObject.
+ *
+ * @param documentObject
+ */
+export declare function validateShallowOADocumentObject(
+  documentObject: OADocumentObject,
+): void;

+ 168 - 0
src/document-validators.js

@@ -0,0 +1,168 @@
+import {InvalidArgumentError} from '@e22m4u/js-format';
+
+/**
+ * Validate shallow OADocumentObject.
+ *
+ * @param {object} documentObject
+ */
+export function validateShallowOADocumentObject(documentObject) {
+  if (
+    !documentObject ||
+    typeof documentObject !== 'object' ||
+    Array.isArray(documentObject)
+  ) {
+    throw new InvalidArgumentError(
+      'OpenAPI Document Object must be an Object, but %v was given.',
+      documentObject,
+    );
+  }
+  // openapi
+  if (!documentObject.openapi || typeof documentObject.openapi !== 'string') {
+    throw new InvalidArgumentError(
+      'Property "openapi" must be a non-empty String, but %v was given.',
+      documentObject.openapi,
+    );
+  }
+  // info
+  if (
+    !documentObject.info ||
+    typeof documentObject.info !== 'object' ||
+    Array.isArray(documentObject.info)
+  ) {
+    throw new InvalidArgumentError(
+      'Property "info" must be an Object, but %v was given.',
+      documentObject.info,
+    );
+  }
+  // jsonSchemaDialect
+  if (documentObject.jsonSchemaDialect !== undefined) {
+    if (
+      !documentObject.jsonSchemaDialect ||
+      typeof documentObject.jsonSchemaDialect !== 'string'
+    ) {
+      throw new InvalidArgumentError(
+        'Property "jsonSchemaDialect" must be a non-empty String, ' +
+          'but %v was given.',
+        documentObject.jsonSchemaDialect,
+      );
+    }
+  }
+  // servers
+  if (documentObject.servers !== undefined) {
+    if (!Array.isArray(documentObject.servers)) {
+      throw new InvalidArgumentError(
+        'Property "servers" must be an Array, but %v was given.',
+        documentObject.servers,
+      );
+    }
+    documentObject.servers.forEach((serverObject, index) => {
+      if (
+        !serverObject ||
+        typeof serverObject !== 'object' ||
+        Array.isArray(serverObject)
+      ) {
+        throw new InvalidArgumentError(
+          'Element "servers[%d]" must be an Object, but %v was given.',
+          index,
+          serverObject,
+        );
+      }
+    });
+  }
+  // paths
+  if (documentObject.paths !== undefined) {
+    if (
+      !documentObject.paths ||
+      typeof documentObject.paths !== 'object' ||
+      Array.isArray(documentObject.paths)
+    ) {
+      throw new InvalidArgumentError(
+        'Property "paths" must be an Object, but %v was given.',
+        documentObject.paths,
+      );
+    }
+  }
+  // webhooks
+  if (documentObject.webhooks !== undefined) {
+    if (
+      !documentObject.webhooks ||
+      typeof documentObject.webhooks !== 'object' ||
+      Array.isArray(documentObject.webhooks)
+    ) {
+      throw new InvalidArgumentError(
+        'Property "webhooks" must be an Object, but %v was given.',
+        documentObject.webhooks,
+      );
+    }
+  }
+  // components
+  if (documentObject.components !== undefined) {
+    if (
+      !documentObject.components ||
+      typeof documentObject.components !== 'object' ||
+      Array.isArray(documentObject.components)
+    ) {
+      throw new InvalidArgumentError(
+        'Property "components" must be an Object, but %v was given.',
+        documentObject.components,
+      );
+    }
+  }
+  // security
+  if (documentObject.security !== undefined) {
+    if (!Array.isArray(documentObject.security)) {
+      throw new InvalidArgumentError(
+        'Property "security" must be an Array, but %v was given.',
+        documentObject.security,
+      );
+    }
+    documentObject.security.forEach((securityRequirementObject, index) => {
+      if (
+        !securityRequirementObject ||
+        typeof securityRequirementObject !== 'object' ||
+        Array.isArray(securityRequirementObject)
+      ) {
+        throw new InvalidArgumentError(
+          'Element "security[%d]" must be an Object, but %v was given.',
+          index,
+          securityRequirementObject,
+        );
+      }
+    });
+  }
+  // tags
+  if (documentObject.tags !== undefined) {
+    if (!Array.isArray(documentObject.tags)) {
+      throw new InvalidArgumentError(
+        'Property "tags" must be an Array, but %v was given.',
+        documentObject.tags,
+      );
+    }
+    documentObject.tags.forEach((tagObject, index) => {
+      if (
+        !tagObject ||
+        typeof tagObject !== 'object' ||
+        Array.isArray(tagObject)
+      ) {
+        throw new InvalidArgumentError(
+          'Element "tags[%d]" must be an Object, but %v was given.',
+          index,
+          tagObject,
+        );
+      }
+    });
+  }
+  // externalDocs
+  if (documentObject.externalDocs !== undefined) {
+    if (
+      !documentObject.externalDocs ||
+      typeof documentObject.externalDocs !== 'object' ||
+      Array.isArray(documentObject.externalDocs)
+    ) {
+      throw new InvalidArgumentError(
+        'Property "externalDocs" must be an Object, but %v was given.',
+        documentObject.externalDocs,
+      );
+    }
+  }
+}

+ 7 - 0
src/index.d.ts

@@ -0,0 +1,7 @@
+export * from './constants.js';
+export * from './oa-document-scope.js';
+export * from './oa-document-builder.js';
+export * from './document-specification.js';
+
+export * from './utils/oa-ref.js';
+export * from './utils/oa-json-content.js';

+ 7 - 0
src/index.js

@@ -0,0 +1,7 @@
+export * from './constants.js';
+export * from './oa-document-scope.js';
+export * from './oa-document-builder.js';
+export * from './document-specification.js';
+
+export * from './utils/oa-ref.js';
+export * from './utils/oa-json-content.js';

+ 238 - 0
src/oa-document-builder.d.ts

@@ -0,0 +1,238 @@
+import {Optional} from './types.js';
+import {Service, ServiceContainer} from '@e22m4u/js-service';
+import {OADocumentScope, OADocumentScopeOptions} from './oa-document-scope.js';
+
+import {
+  OALinkObject,
+  OASchemaObject,
+  OAHeaderObject,
+  OAExampleObject,
+  OADocumentObject,
+  OACallbackObject,
+  OAResponseObject,
+  OAPathItemObject,
+  OAOperationMethod,
+  OAOperationObject,
+  OAReferenceObject,
+  OAParameterObject,
+  OARequestBodyObject,
+  OASecuritySchemeObject,
+} from './document-specification.js';
+
+/**
+ * Document input.
+ */
+export type OADocumentInput = Optional<OADocumentObject, 'openapi' | 'info'>;
+
+/**
+ * Schema component definition.
+ */
+export type OASchemaComponentDefinition = {
+  name: string;
+  schema: OASchemaObject;
+};
+
+/**
+ * Response component definition.
+ */
+export type OAResponseComponentDefinition = {
+  name: string;
+  response: OAResponseObject | OAReferenceObject;
+};
+
+/**
+ * Parameter component definition.
+ */
+export type OAParameterComponentDefinition = {
+  name: string;
+  parameter: OAParameterObject | OAReferenceObject;
+};
+
+/**
+ * Example component definition.
+ */
+export type OAExampleComponentDefinition = {
+  name: string;
+  example: OAExampleObject | OAReferenceObject;
+};
+
+/**
+ * Request body component definition.
+ */
+export type OARequestBodyComponentDefinition = {
+  name: string;
+  requestBody: OARequestBodyObject | OAReferenceObject;
+};
+
+/**
+ * Header component definition.
+ */
+export type OAHeaderComponentDefinition = {
+  name: string;
+  header: OAHeaderObject | OAReferenceObject;
+};
+
+/**
+ * Security scheme component definition.
+ */
+export type OASecuritySchemeComponentDefinition = {
+  name: string;
+  securityScheme: OASecuritySchemeObject | OAReferenceObject;
+};
+
+/**
+ * Link component definition.
+ */
+export type OALinkComponentDefinition = {
+  name: string;
+  link: OALinkObject | OAReferenceObject;
+};
+
+/**
+ * Callback component definition.
+ */
+export type OACallbackComponentDefinition = {
+  name: string;
+  callback: OACallbackObject | OAReferenceObject;
+};
+
+/**
+ * Path item component definition.
+ */
+export type OAPathItemComponentDefinition = {
+  name: string;
+  pathItem: OAPathItemObject | OAReferenceObject;
+};
+
+/**
+ * Operation definition.
+ */
+export type OAOperationDefinition = {
+  path: string;
+  method: OAOperationMethod;
+  operation: OAOperationObject;
+};
+
+/**
+ * Document builder.
+ */
+export declare class OADocumentBuilder extends Service {
+  /**
+   * Constructor.
+   *
+   * @param container
+   * @param document
+   */
+  constructor(container: ServiceContainer, document?: OADocumentInput);
+
+  /**
+   * Constructor.
+   *
+   * @param document
+   */
+  constructor(document?: OADocumentInput);
+
+  /**
+   * Constructor.
+   *
+   * @param containerOrDocument
+   * @param document
+   */
+  constructor(
+    containerOrDocument?: ServiceContainer | OADocumentInput,
+    document?: OADocumentInput,
+  );
+
+  /**
+   * Define schema component.
+   *
+   * @param schemaDef
+   */
+  defineSchemaComponent(schemaDef: OASchemaComponentDefinition): this;
+
+  /**
+   * Define response component.
+   *
+   * @param responseDef
+   */
+  defineResponseComponent(responseDef: OAResponseComponentDefinition): this;
+
+  /**
+   * Define parameter component.
+   *
+   * @param parameterDef
+   */
+  defineParameterComponent(parameterDef: OAParameterComponentDefinition): this;
+
+  /**
+   * Define example component.
+   *
+   * @param exampleDef
+   */
+  defineExampleComponent(exampleDef: OAExampleComponentDefinition): this;
+
+  /**
+   * Define request body component.
+   *
+   * @param requestBodyDef
+   */
+  defineRequestBodyComponent(
+    requestBodyDef: OARequestBodyComponentDefinition,
+  ): this;
+
+  /**
+   * Define header component.
+   *
+   * @param headerDef
+   */
+  defineHeaderComponent(headerDef: OAHeaderComponentDefinition): this;
+
+  /**
+   * Define security scheme component.
+   *
+   * @param securitySchemeDef
+   */
+  defineSecuritySchemeComponent(
+    securitySchemeDef: OASecuritySchemeComponentDefinition,
+  ): this;
+
+  /**
+   * Define link component.
+   *
+   * @param linkDef
+   */
+  defineLinkComponent(linkDef: OALinkComponentDefinition): this;
+
+  /**
+   * Define callback component.
+   *
+   * @param callbackDef
+   */
+  defineCallbackComponent(callbackDef: OACallbackComponentDefinition): this;
+
+  /**
+   * Define path item component.
+   *
+   * @param pathItemDef
+   */
+  definePathItemComponent(pathItemDef: OAPathItemComponentDefinition): this;
+
+  /**
+   * Define operation.
+   *
+   * @param operationDef
+   */
+  defineOperation(operationDef: OAOperationDefinition): this;
+
+  /**
+   * Create scope.
+   *
+   * @param options
+   */
+  createScope(options?: OADocumentScopeOptions): OADocumentScope;
+
+  /**
+   * Build.
+   */
+  build(): OADocumentObject;
+}

+ 381 - 0
src/oa-document-builder.js

@@ -0,0 +1,381 @@
+import {OPENAPI_VERSION} from './constants.js';
+import {OADocumentScope} from './oa-document-scope.js';
+import {InvalidArgumentError} from '@e22m4u/js-format';
+import {OAOperationMethod} from './document-specification.js';
+import {isServiceContainer, Service} from '@e22m4u/js-service';
+import {validateShallowOADocumentObject} from './document-validators.js';
+
+/**
+ * OpenAPI Document builder.
+ */
+export class OADocumentBuilder extends Service {
+  /**
+   * Document.
+   *
+   * @private
+   * @type {object}
+   */
+  _document = {
+    openapi: OPENAPI_VERSION,
+    info: {
+      title: 'API Documentation',
+      version: '0.0.1',
+    },
+  };
+
+  /**
+   * Constructor.
+   *
+   * @param {object} [containerOrDocument]
+   * @param {object} [document]
+   */
+  constructor(containerOrDocument, document) {
+    if (isServiceContainer(containerOrDocument)) {
+      super(containerOrDocument);
+    } else {
+      super();
+      document = containerOrDocument;
+    }
+    if (document !== undefined) {
+      // если схема документа не является
+      // объектом, то выбрасывается ошибка
+      if (
+        !document ||
+        typeof document !== 'object' ||
+        Array.isArray(document)
+      ) {
+        throw new InvalidArgumentError(
+          'OpenAPI Document Object must be an Object, but %v was given.',
+          document,
+        );
+      }
+      // чтобы избежать мутации аргумента,
+      // выполняется копирование документа
+      document = structuredClone(document);
+      // если версия OpenAPI не определена,
+      // то устанавливается версия по умолчанию
+      if (!document.openapi) {
+        document.openapi = this._document.openapi;
+      }
+      // если Info Object не определен,
+      // то устанавливается значение по умолчанию
+      if (!document.info) {
+        document.info = this._document.info;
+      }
+      // если свойство "info" не является
+      // объектом, то выбрасывается ошибка
+      if (
+        !document.info ||
+        typeof document.info !== 'object' ||
+        Array.isArray(document.info)
+      ) {
+        throw new InvalidArgumentError(
+          'Property "info" must be an Object, but %v was given.',
+          document.info,
+        );
+      }
+      // если свойство "info.title" не определено,
+      // то устанавливается значение по умолчанию
+      if (!document.info.title) {
+        document.info.title = this._document.info.title;
+      }
+      // если свойство "info.version" не определено,
+      // то устанавливается значение по умолчанию
+      if (!document.info.version) {
+        document.info.version = this._document.info.version;
+      }
+      // поверхностная проверка свойств документа
+      // выполняется после установки значений по умолчанию
+      validateShallowOADocumentObject(document);
+      this._document = document;
+    }
+  }
+
+  /**
+   * Define component.
+   *
+   * @param {string} targetKey
+   * @param {string} componentKey
+   * @param {string} definitionLabel
+   * @param {object} definition
+   * @returns {this}
+   */
+  _defineComponent(targetKey, componentKey, definitionLabel, definition) {
+    // targetKey
+    if (!targetKey || typeof targetKey !== 'string') {
+      throw new InvalidArgumentError(
+        'Property "targetKey" must be a non-empty String, ' +
+          'but %v was given.',
+        targetKey,
+      );
+    }
+    // componentKey
+    if (!componentKey || typeof componentKey !== 'string') {
+      throw new InvalidArgumentError(
+        'Property "componentKey" must be a non-empty String, ' +
+          'but %v was given.',
+        componentKey,
+      );
+    }
+    // definitionLabel
+    if (!definitionLabel || typeof definitionLabel !== 'string') {
+      throw new InvalidArgumentError(
+        'Property "definitionLabel" must be a non-empty String, ' +
+          'but %v was given.',
+        definitionLabel,
+      );
+    }
+    // definition
+    if (
+      !definition ||
+      typeof definition !== 'object' ||
+      Array.isArray(definition)
+    ) {
+      throw new InvalidArgumentError(
+        '%s Definition must be an Object, but %v was given.',
+        definitionLabel,
+        definition,
+      );
+    }
+    // definition.name
+    if (!definition.name || typeof definition.name !== 'string') {
+      throw new InvalidArgumentError(
+        'Property "name" must be a non-empty String, but %v was given.',
+        definition.name,
+      );
+    }
+    // definition[componentKey]
+    const component = definition[componentKey];
+    if (
+      !component ||
+      typeof component !== 'object' ||
+      Array.isArray(component)
+    ) {
+      throw new InvalidArgumentError(
+        'Property %v must be an Object, but %v was given.',
+        componentKey,
+        component,
+      );
+    }
+    if (!this._document.components) {
+      this._document.components = {};
+    }
+    if (!this._document.components[targetKey]) {
+      this._document.components[targetKey] = {};
+    }
+    this._document.components[targetKey][definition.name] = component;
+    return this;
+  }
+
+  /**
+   * Define schema component.
+   *
+   * @param {import('./oa-document-builder.js').OASchemaComponentDefinition} schemaDef
+   * @returns {this}
+   */
+  defineSchemaComponent(schemaDef) {
+    return this._defineComponent('schemas', 'schema', 'Schema', schemaDef);
+  }
+
+  /**
+   * Define response component.
+   *
+   * @param {import('./oa-document-builder.js').OAResponseComponentDefinition} responseDef
+   * @returns {this}
+   */
+  defineResponseComponent(responseDef) {
+    return this._defineComponent(
+      'responses',
+      'response',
+      'Response',
+      responseDef,
+    );
+  }
+
+  /**
+   * Define parameter component.
+   *
+   * @param {import('./oa-document-builder.js').OAParameterComponentDefinition} parameterDef
+   * @returns {this}
+   */
+  defineParameterComponent(parameterDef) {
+    return this._defineComponent(
+      'parameters',
+      'parameter',
+      'Parameter',
+      parameterDef,
+    );
+  }
+
+  /**
+   * Define example component.
+   *
+   * @param {import('./oa-document-builder.js').OAExampleComponentDefinition} exampleDef
+   * @returns {this}
+   */
+  defineExampleComponent(exampleDef) {
+    return this._defineComponent('examples', 'example', 'Example', exampleDef);
+  }
+
+  /**
+   * Define request body component.
+   *
+   * @param {import('./oa-document-builder.js').OARequestBodyComponentDefinition} requestBodyDef
+   * @returns {this}
+   */
+  defineRequestBodyComponent(requestBodyDef) {
+    return this._defineComponent(
+      'requestBodies',
+      'requestBody',
+      'Request body',
+      requestBodyDef,
+    );
+  }
+
+  /**
+   * Define header component.
+   *
+   * @param {import('./oa-document-builder.js').OAHeaderComponentDefinition} headerDef
+   * @returns {this}
+   */
+  defineHeaderComponent(headerDef) {
+    return this._defineComponent('headers', 'header', 'Header', headerDef);
+  }
+
+  /**
+   * Define security scheme component.
+   *
+   * @param {import('./oa-document-builder.js').OASecuritySchemeComponentDefinition} securitySchemeDef
+   * @returns {this}
+   */
+  defineSecuritySchemeComponent(securitySchemeDef) {
+    return this._defineComponent(
+      'securitySchemes',
+      'securityScheme',
+      'Security Scheme',
+      securitySchemeDef,
+    );
+  }
+
+  /**
+   * Define link component.
+   *
+   * @param {import('./oa-document-builder.js').OALinkComponentDefinition} linkDef
+   * @returns {this}
+   */
+  defineLinkComponent(linkDef) {
+    return this._defineComponent('links', 'link', 'Link', linkDef);
+  }
+
+  /**
+   * Define callback component.
+   *
+   * @param {import('./oa-document-builder.js').OACallbackComponentDefinition} callbackDef
+   * @returns {this}
+   */
+  defineCallbackComponent(callbackDef) {
+    return this._defineComponent(
+      'callbacks',
+      'callback',
+      'Callback',
+      callbackDef,
+    );
+  }
+
+  /**
+   * Define path item component.
+   *
+   * @param {import('./oa-document-builder.js').OAPathItemComponentDefinition} pathItemDef
+   * @returns {this}
+   */
+  definePathItemComponent(pathItemDef) {
+    return this._defineComponent(
+      'pathItems',
+      'pathItem',
+      'Path Item',
+      pathItemDef,
+    );
+  }
+
+  /**
+   * Define operation.
+   *
+   * @param {object} operationDef
+   */
+  defineOperation(operationDef) {
+    if (
+      !operationDef ||
+      typeof operationDef !== 'object' ||
+      Array.isArray(operationDef)
+    ) {
+      throw new InvalidArgumentError(
+        'Operation Definition must be an Object, but %v was given.',
+        operationDef,
+      );
+    }
+    // path
+    if (!operationDef.path || typeof operationDef.path !== 'string') {
+      throw new InvalidArgumentError(
+        'Property "path" must be a non-empty String, but %v was given.',
+        operationDef.path,
+      );
+    }
+    if (operationDef.path[0] !== '/') {
+      throw new InvalidArgumentError(
+        'Property "path" must start with forward slash "/", but %v was given.',
+        operationDef.path,
+      );
+    }
+    // method
+    if (!operationDef.method || typeof operationDef.method !== 'string') {
+      throw new InvalidArgumentError(
+        'Property "method" must be a non-empty String, but %v was given.',
+        operationDef.method,
+      );
+    }
+    if (!Object.values(OAOperationMethod).includes(operationDef.method)) {
+      throw new InvalidArgumentError(
+        'Property "method" must be one of values: %l, but %v was given.',
+        Object.values(OAOperationMethod),
+      );
+    }
+    // operation
+    if (
+      !operationDef.operation ||
+      typeof operationDef.operation !== 'object' ||
+      Array.isArray(operationDef.operation)
+    ) {
+      throw new InvalidArgumentError(
+        'Property "operation" must be an Object, but %v was given.',
+        operationDef.operation,
+      );
+    }
+    if (!this._document.paths) {
+      this._document.paths = {};
+    }
+    if (!this._document.paths[operationDef.path]) {
+      this._document.paths[operationDef.path] = {};
+    }
+    this._document.paths[operationDef.path][operationDef.method] =
+      structuredClone(operationDef.operation);
+  }
+
+  /**
+   * Create scope.
+   *
+   * @param {object} [options]
+   * @returns {OADocumentScope}
+   */
+  createScope(options) {
+    return new OADocumentScope(this, options);
+  }
+
+  /**
+   * Build.
+   *
+   * @returns {object}
+   */
+  build() {
+    return structuredClone(this._document);
+  }
+}

+ 44 - 0
src/oa-document-scope.d.ts

@@ -0,0 +1,44 @@
+import {OADocumentObject} from './document-specification.js';
+
+import {
+  OADocumentBuilder,
+  OAOperationDefinition,
+} from './oa-document-builder.js';
+
+/**
+ * Document scope options.
+ */
+export type OADocumentScopeOptions = {
+  pathPrefix?: string;
+  tags?: string[];
+};
+
+/**
+ * Document scope.
+ */
+export declare class OADocumentScope {
+  /**
+   * @param rootBuilder
+   * @param options
+   */
+  constructor(rootBuilder: OADocumentBuilder, options?: OADocumentScopeOptions);
+
+  /**
+   * Define operation.
+   *
+   * @param operationDef
+   */
+  defineOperation(operationDef: OAOperationDefinition): this;
+
+  /**
+   * Create scope.
+   *
+   * @param options
+   */
+  createScope(options?: OADocumentScopeOptions): OADocumentScope;
+
+  /**
+   * Build.
+   */
+  build(): OADocumentObject;
+}

+ 190 - 0
src/oa-document-scope.js

@@ -0,0 +1,190 @@
+import {joinPath} from './utils/index.js';
+import {InvalidArgumentError} from '@e22m4u/js-format';
+import {OADocumentBuilder} from './oa-document-builder.js';
+import {OAOperationMethod} from './document-specification.js';
+
+/**
+ * Document scope.
+ */
+export class OADocumentScope {
+  /**
+   * @param {object} rootBuilder
+   * @param {object} [options]
+   */
+  constructor(rootBuilder, options = {}) {
+    if (!(rootBuilder instanceof OADocumentBuilder)) {
+      throw new InvalidArgumentError(
+        'Parameter "rootBuilder" must be an instance of OADocumentBuilder, ' +
+          'but %v was given.',
+        rootBuilder,
+      );
+    }
+    if (!options || typeof options !== 'object' || Array.isArray(options)) {
+      throw new InvalidArgumentError(
+        'Parameter "options" must be an Object, but %v was given.',
+        options,
+      );
+    }
+    if (options.pathPrefix !== undefined) {
+      if (!options.pathPrefix || typeof options.pathPrefix !== 'string') {
+        throw new InvalidArgumentError(
+          'Parameter "pathPrefix" must be a non-empty String, ' +
+            'but %v was given.',
+          options.pathPrefix,
+        );
+      }
+    }
+    if (options.tags !== undefined) {
+      if (!Array.isArray(options.tags)) {
+        throw new InvalidArgumentError(
+          'Parameter "tags" must be an Array, ' + 'but %v was given.',
+          options.tags,
+        );
+      }
+      options.tags.forEach((tag, index) => {
+        if (!tag || typeof tag !== 'string') {
+          throw new InvalidArgumentError(
+            'Element "tags[%d]" must be a non-empty String, ' +
+              'but %v was given.',
+            index,
+            tag,
+          );
+        }
+      });
+    }
+    this.rootBuilder = rootBuilder;
+    this.pathPrefix = options.pathPrefix || '/';
+    this.tags = options.tags || [];
+  }
+
+  /**
+   * Define operation.
+   *
+   * @param {object} operationDef
+   * @returns {this}
+   */
+  defineOperation(operationDef) {
+    if (
+      !operationDef ||
+      typeof operationDef !== 'object' ||
+      Array.isArray(operationDef)
+    ) {
+      throw new InvalidArgumentError(
+        'Operation Definition must be an Object, but %v was given.',
+        operationDef,
+      );
+    }
+    // path
+    if (!operationDef.path || typeof operationDef.path !== 'string') {
+      throw new InvalidArgumentError(
+        'Property "path" must be a non-empty String, but %v was given.',
+        operationDef.path,
+      );
+    }
+    if (operationDef.path[0] !== '/') {
+      throw new InvalidArgumentError(
+        'Property "path" must start with forward slash "/", but %v was given.',
+        operationDef.path,
+      );
+    }
+    // method
+    if (!operationDef.method || typeof operationDef.method !== 'string') {
+      throw new InvalidArgumentError(
+        'Property "method" must be a non-empty String, but %v was given.',
+        operationDef.method,
+      );
+    }
+    if (!Object.values(OAOperationMethod).includes(operationDef.method)) {
+      throw new InvalidArgumentError(
+        'Property "method" must be one of values: %l, but %v was given.',
+        Object.values(OAOperationMethod),
+      );
+    }
+    // operation
+    if (
+      !operationDef.operation ||
+      typeof operationDef.operation !== 'object' ||
+      Array.isArray(operationDef.operation)
+    ) {
+      throw new InvalidArgumentError(
+        'Property "operation" must be an Object, but %v was given.',
+        operationDef.operation,
+      );
+    }
+    // склеивание пути
+    const fullPath = joinPath(this.pathPrefix, operationDef.path);
+    // создание копии схемы операции
+    // чтобы избежать мутацию аргумента
+    const operation = structuredClone(operationDef.operation);
+    // объединение тегов текущей области
+    // с тегами текущей операции и удаление
+    // дубликатов
+    if (this.tags.length > 0) {
+      operation.tags = [...this.tags, ...(operation.tags || [])];
+      operation.tags = [...new Set(operation.tags)];
+    }
+    // регистрация операции в родительском
+    // экземпляре сборщика документа
+    this.rootBuilder.defineOperation({
+      ...operationDef,
+      path: fullPath,
+      operation,
+    });
+    return this;
+  }
+
+  /**
+   * Create scope.
+   *
+   * @param {object} [options]
+   * @returns {OADocumentScope}
+   */
+  createScope(options = {}) {
+    if (!options || typeof options !== 'object' || Array.isArray(options)) {
+      throw new InvalidArgumentError(
+        'Parameter "options" must be an Object, but %v was given.',
+        options,
+      );
+    }
+    if (options.pathPrefix !== undefined) {
+      if (!options.pathPrefix || typeof options.pathPrefix !== 'string') {
+        throw new InvalidArgumentError(
+          'Parameter "pathPrefix" must be a non-empty String, ' +
+            'but %v was given.',
+          options.pathPrefix,
+        );
+      }
+    }
+    if (options.tags !== undefined) {
+      if (!Array.isArray(options.tags)) {
+        throw new InvalidArgumentError(
+          'Parameter "tags" must be an Array, ' + 'but %v was given.',
+          options.tags,
+        );
+      }
+      options.tags.forEach((tag, index) => {
+        if (!tag || typeof tag !== 'string') {
+          throw new InvalidArgumentError(
+            'Element "tags[%d]" must be a non-empty String, ' +
+              'but %v was given.',
+            index,
+            tag,
+          );
+        }
+      });
+    }
+    return new OADocumentScope(this.rootBuilder, {
+      pathPrefix: joinPath(this.pathPrefix, options.pathPrefix),
+      tags: [...this.tags, ...(options.tags || [])],
+    });
+  }
+
+  /**
+   * Build.
+   *
+   * @returns {object}
+   */
+  build() {
+    return this.rootBuilder.build();
+  }
+}

+ 4 - 0
src/types.d.ts

@@ -0,0 +1,4 @@
+/**
+ * Make specific properties optional.
+ */
+export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

+ 3 - 0
src/utils/index.d.ts

@@ -0,0 +1,3 @@
+export * from './oa-ref.js';
+export * from './join-path.js';
+export * from './oa-json-content.js';

+ 3 - 0
src/utils/index.js

@@ -0,0 +1,3 @@
+export * from './oa-ref.js';
+export * from './join-path.js';
+export * from './oa-json-content.js';

+ 6 - 0
src/utils/join-path.d.ts

@@ -0,0 +1,6 @@
+/**
+ * Joins URL paths.
+ *
+ * @param segments
+ */
+export declare function joinPath(...segments: string[]): string;

+ 14 - 0
src/utils/join-path.js

@@ -0,0 +1,14 @@
+/**
+ * Joins URL paths.
+ *
+ * @param {...string} segments
+ * @returns {string}
+ */
+export function joinPath(...segments) {
+  const path = segments
+    .filter(Boolean)
+    .map(seg => seg.replace(/(^\/|\/$)/g, ''))
+    .filter(Boolean)
+    .join('/');
+  return '/' + path;
+}

+ 64 - 0
src/utils/join-path.spec.js

@@ -0,0 +1,64 @@
+import {expect} from 'chai';
+import {joinPath} from './join-path.js';
+
+describe('joinPath', function() {
+  it('should join multiple segments with a forward slash', function() {
+    const result = joinPath('api', 'v1', 'users');
+    expect(result).to.be.eq('/api/v1/users');
+  });
+
+  it('should always return a path starting with a single forward slash', function() {
+    expect(joinPath('users')).to.be.eq('/users');
+    expect(joinPath('/users')).to.be.eq('/users');
+  });
+
+  it('should remove leading slashes from segments', function() {
+    const result = joinPath('/api', '/v1', '/users');
+    expect(result).to.be.eq('/api/v1/users');
+  });
+
+  it('should remove trailing slashes from segments', function() {
+    const result = joinPath('api/', 'v1/', 'users/');
+    expect(result).to.be.eq('/api/v1/users');
+  });
+
+  it('should handle segments with both leading and trailing slashes', function() {
+    const result = joinPath('/api/', '/v1/', '/users/');
+    expect(result).to.be.eq('/api/v1/users');
+  });
+
+  it('should ignore empty string segments', function() {
+    const result = joinPath('api', '', 'v1', '', 'users');
+    expect(result).to.be.eq('/api/v1/users');
+  });
+
+  it('should ignore null and undefined segments', function() {
+    const result = joinPath('api', null, 'v1', undefined, 'users');
+    expect(result).to.be.eq('/api/v1/users');
+  });
+
+  it('should handle a single segment correctly', function() {
+    expect(joinPath('users')).to.be.eq('/users');
+    expect(joinPath('/users/')).to.be.eq('/users');
+  });
+
+  it('should return a single slash when no segments are provided', function() {
+    const result = joinPath();
+    expect(result).to.be.eq('/');
+  });
+
+  it('should return a single slash if all segments are empty or falsy', function() {
+    const result = joinPath('', null, '', undefined);
+    expect(result).to.be.eq('/');
+  });
+
+  it('should treat a single slash segment as an empty segment', function() {
+    const result = joinPath('api', '/', 'users');
+    expect(result).to.be.eq('/api/users');
+  });
+
+  it('should not add extra slashes if segments are already joined', function() {
+    const result = joinPath('api/v1', 'users');
+    expect(result).to.be.eq('/api/v1/users');
+  });
+});

+ 39 - 0
src/utils/oa-json-content.d.ts

@@ -0,0 +1,39 @@
+import {
+  OASchemaObject,
+  OAContentObject,
+  OAMediaTypeObject,
+  OAReferenceObject,
+} from '../document-specification.js';
+
+/**
+ * Create "application/json" content.
+ * 
+ * Example:
+ * 
+ * ```js
+ * builder.defineOperation({
+ *   path: '/users',
+ *   method: 'get',
+ *   operation: {
+ *     responses: {
+ *       200: {
+ *         description: 'List of users',
+ *         content: oaJsonContent({ // <=
+ *           type: 'array',
+ *           items: {
+ *             $ref: '#/components/schemas/User',
+ *           },
+ *         }),
+ *       },
+ *     },
+ *   },
+ * });
+ * ```
+ * 
+ * @param schema
+ * @param options
+ */
+export declare function oaJsonContent(
+  schema: OASchemaObject | OAReferenceObject,
+  options?: Omit<OAMediaTypeObject, 'schema'>
+): OAContentObject;

+ 51 - 0
src/utils/oa-json-content.js

@@ -0,0 +1,51 @@
+import {InvalidArgumentError} from '@e22m4u/js-format';
+
+/**
+ * Create "application/json" content.
+ * 
+ * Example:
+ * 
+ * ```js
+ * builder.defineOperation({
+ *   path: '/users',
+ *   method: 'get',
+ *   operation: {
+ *     responses: {
+ *       200: {
+ *         description: 'List of users',
+ *         content: oaJsonContent({ // <=
+ *           type: 'array',
+ *           items: {
+ *             $ref: '#/components/schemas/User',
+ *           },
+ *         }),
+ *       },
+ *     },
+ *   },
+ * });
+ * ```
+ *
+ * @param {object} schema
+ * @param {object} [options]
+ * @returns {object}
+ */
+export function oaJsonContent(schema, options = {}) {
+  if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
+    throw new InvalidArgumentError(
+      'Parameter "schema" must be an Object, but %v was given.',
+      schema,
+    );
+  }
+  if (!options || typeof options !== 'object' || Array.isArray(options)) {
+    throw new InvalidArgumentError(
+      'Parameter "options" must be an Object, but %v was given.',
+      options,
+    );
+  }
+  return {
+    'application/json': {
+      schema,
+      ...options,
+    },
+  };
+}

+ 53 - 0
src/utils/oa-ref.d.ts

@@ -0,0 +1,53 @@
+import {
+  OAReferenceObject,
+  OAComponentsObject,
+} from '../document-specification.js';
+
+/**
+ * Create the Reference Object.
+ *
+ * Example 1:
+ * 
+ * ```js
+ * oaRef('User');
+ * // {"$ref": "#/components/schemas/User"}
+ * ```
+ * 
+ * Example 2:
+ * 
+ * ```js
+ * oaRef('Error', 'responses');
+ * // {"$ref": "#/components/responses/Error"}
+ * ```
+ * 
+ * Example 3:
+ * 
+ * ```js
+ * builder.defineOperation({
+ *   path: '/users',
+ *   method: 'get',
+ *   operation: {
+ *     responses: {
+ *       200: {
+ *         description: 'List of users',
+ *         content: {
+ *           'application/json': {
+ *             schema: {
+ *               type: 'array',
+ *               items: oaRef('User'), // <=
+ *             },
+ *           },
+ *         },
+ *       },
+ *     },
+ *   },
+ * });
+ * ```
+ *
+ * @param name
+ * @param type
+ */
+export declare function oaRef(
+  name: string,
+  type?: keyof OAComponentsObject,
+): OAReferenceObject;

+ 48 - 0
src/utils/oa-ref.js

@@ -0,0 +1,48 @@
+/**
+ * Create Reference Object.
+ *
+ * Example 1:
+ * 
+ * ```js
+ * oaRef('User');
+ * // {"$ref": "#/components/schemas/User"}
+ * ```
+ * 
+ * Example 2:
+ * 
+ * ```js
+ * oaRef('Error', 'responses');
+ * // {"$ref": "#/components/responses/Error"}
+ * ```
+ * 
+ * Example 3:
+ * 
+ * ```js
+ * builder.defineOperation({
+ *   path: '/users',
+ *   method: 'get',
+ *   operation: {
+ *     responses: {
+ *       200: {
+ *         description: 'List of users',
+ *         content: {
+ *           'application/json': {
+ *             schema: {
+ *               type: 'array',
+ *               items: oaRef('User'), // <=
+ *             },
+ *           },
+ *         },
+ *       },
+ *     },
+ *   },
+ * });
+ * ```
+ *
+ * @param {string} name
+ * @param {string} [type]
+ * @returns {object}
+ */
+export function oaRef(name, type = 'schemas') {
+  return {$ref: `#/components/${type}/${name}`};
+}

+ 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"
+  ]
+}