Browse Source

feat: adds path normalization

e22m4u 6 days ago
parent
commit
acbc5f6b8c

+ 81 - 38
dist/cjs/index.cjs

@@ -61,6 +61,9 @@ var OAComponentsSegment = {
   PATH_ITEMS: "pathItems"
   PATH_ITEMS: "pathItems"
 };
 };
 
 
+// src/oa-document-scope.js
+var import_js_format4 = require("@e22m4u/js-format");
+
 // src/utils/oa-ref.js
 // src/utils/oa-ref.js
 var import_js_format = require("@e22m4u/js-format");
 var import_js_format = require("@e22m4u/js-format");
 function oaRef(name, segment) {
 function oaRef(name, segment) {
@@ -92,13 +95,20 @@ var oaPathItemRef = /* @__PURE__ */ __name((name) => oaRef(name, OAComponentsSeg
 
 
 // src/utils/join-path.js
 // src/utils/join-path.js
 function joinPath(...segments) {
 function joinPath(...segments) {
-  const path = segments.filter((seg) => seg != void 0).map((seg) => String(seg).replace(/(^\/|\/$)/g, "")).filter(Boolean).join("/");
-  return "/" + path;
+  const path = segments.filter((seg) => seg != void 0).map((seg) => String(seg).replace(/(^\/+|\/+$)/g, "")).filter(Boolean).join("/");
+  return ("/" + path).replace(/\/+/g, "/");
 }
 }
 __name(joinPath, "joinPath");
 __name(joinPath, "joinPath");
 
 
-// src/oa-document-scope.js
-var import_js_format4 = require("@e22m4u/js-format");
+// src/utils/normalize-path.js
+function normalizePath(value, noStartingSlash = false) {
+  if (typeof value !== "string") {
+    return "/";
+  }
+  const res = value.trim().replace(/\/+/g, "/").replace(/(^\/|\/$)/g, "");
+  return noStartingSlash ? res : "/" + res;
+}
+__name(normalizePath, "normalizePath");
 
 
 // src/oa-document-builder.js
 // src/oa-document-builder.js
 var import_js_format3 = require("@e22m4u/js-format");
 var import_js_format3 = require("@e22m4u/js-format");
@@ -556,12 +566,6 @@ var OADocumentBuilder = class extends import_js_service.Service {
         operationDef.path
         operationDef.path
       );
       );
     }
     }
-    if (operationDef.path[0] !== "/") {
-      throw new import_js_format3.InvalidArgumentError(
-        'Property "path" must start with forward slash "/", but %v was given.',
-        operationDef.path
-      );
-    }
     if (!operationDef.method || typeof operationDef.method !== "string") {
     if (!operationDef.method || typeof operationDef.method !== "string") {
       throw new import_js_format3.InvalidArgumentError(
       throw new import_js_format3.InvalidArgumentError(
         'Property "method" must be a non-empty String, but %v was given.',
         'Property "method" must be a non-empty String, but %v was given.',
@@ -580,13 +584,16 @@ var OADocumentBuilder = class extends import_js_service.Service {
         operationDef.operation
         operationDef.operation
       );
       );
     }
     }
+    const normalizedPath = normalizePath(operationDef.path);
     if (!this._document.paths) {
     if (!this._document.paths) {
       this._document.paths = {};
       this._document.paths = {};
     }
     }
-    if (!this._document.paths[operationDef.path]) {
-      this._document.paths[operationDef.path] = {};
+    if (!this._document.paths[normalizedPath]) {
+      this._document.paths[normalizedPath] = {};
     }
     }
-    this._document.paths[operationDef.path][operationDef.method] = structuredClone(operationDef.operation);
+    this._document.paths[normalizedPath][operationDef.method] = structuredClone(
+      operationDef.operation
+    );
   }
   }
   /**
   /**
    * Create scope.
    * Create scope.
@@ -622,14 +629,32 @@ var OADocumentScope = class _OADocumentScope {
     __name(this, "OADocumentScope");
     __name(this, "OADocumentScope");
   }
   }
   /**
   /**
-   * @param {object} rootBuilder
+   * Builder.
+   *
+   * @type {OADocumentBuilder}
+   */
+  _builder;
+  /**
+   * Path prefix.
+   *
+   * @type {string}
+   */
+  _pathPrefix;
+  /**
+   * Tags.
+   *
+   * @type {string[]}
+   */
+  _tags;
+  /**
+   * @param {object} builder
    * @param {object} [options]
    * @param {object} [options]
    */
    */
-  constructor(rootBuilder, options = {}) {
-    if (!(rootBuilder instanceof OADocumentBuilder)) {
+  constructor(builder, options = {}) {
+    if (!(builder instanceof OADocumentBuilder)) {
       throw new import_js_format4.InvalidArgumentError(
       throw new import_js_format4.InvalidArgumentError(
-        'Parameter "rootBuilder" must be an instance of OADocumentBuilder, but %v was given.',
-        rootBuilder
+        'Parameter "builder" must be an instance of OADocumentBuilder, but %v was given.',
+        builder
       );
       );
     }
     }
     if (!options || typeof options !== "object" || Array.isArray(options)) {
     if (!options || typeof options !== "object" || Array.isArray(options)) {
@@ -641,7 +666,7 @@ var OADocumentScope = class _OADocumentScope {
     if (options.pathPrefix !== void 0) {
     if (options.pathPrefix !== void 0) {
       if (!options.pathPrefix || typeof options.pathPrefix !== "string") {
       if (!options.pathPrefix || typeof options.pathPrefix !== "string") {
         throw new import_js_format4.InvalidArgumentError(
         throw new import_js_format4.InvalidArgumentError(
-          'Parameter "pathPrefix" must be a non-empty String, but %v was given.',
+          'Property "pathPrefix" must be a non-empty String, but %v was given.',
           options.pathPrefix
           options.pathPrefix
         );
         );
       }
       }
@@ -649,7 +674,7 @@ var OADocumentScope = class _OADocumentScope {
     if (options.tags !== void 0) {
     if (options.tags !== void 0) {
       if (!Array.isArray(options.tags)) {
       if (!Array.isArray(options.tags)) {
         throw new import_js_format4.InvalidArgumentError(
         throw new import_js_format4.InvalidArgumentError(
-          'Parameter "tags" must be an Array, but %v was given.',
+          'Property "tags" must be an Array, but %v was given.',
           options.tags
           options.tags
         );
         );
       }
       }
@@ -663,9 +688,33 @@ var OADocumentScope = class _OADocumentScope {
         }
         }
       });
       });
     }
     }
-    this.rootBuilder = rootBuilder;
-    this.pathPrefix = options.pathPrefix || "/";
-    this.tags = options.tags || [];
+    this._builder = builder;
+    this._pathPrefix = normalizePath(options.pathPrefix || "/");
+    this._tags = options.tags || [];
+  }
+  /**
+   * Get builder.
+   *
+   * @returns {OADocumentBuilder}
+   */
+  getBuilder() {
+    return this._builder;
+  }
+  /**
+   * Get path prefix.
+   *
+   * @returns {string}
+   */
+  getPathPrefix() {
+    return this._pathPrefix;
+  }
+  /**
+   * Get tags.
+   *
+   * @returns {string[]}
+   */
+  getTags() {
+    return [...this._tags];
   }
   }
   /**
   /**
    * Define operation.
    * Define operation.
@@ -686,12 +735,6 @@ var OADocumentScope = class _OADocumentScope {
         operationDef.path
         operationDef.path
       );
       );
     }
     }
-    if (operationDef.path[0] !== "/") {
-      throw new import_js_format4.InvalidArgumentError(
-        'Property "path" must start with forward slash "/", but %v was given.',
-        operationDef.path
-      );
-    }
     if (!operationDef.method || typeof operationDef.method !== "string") {
     if (!operationDef.method || typeof operationDef.method !== "string") {
       throw new import_js_format4.InvalidArgumentError(
       throw new import_js_format4.InvalidArgumentError(
         'Property "method" must be a non-empty String, but %v was given.',
         'Property "method" must be a non-empty String, but %v was given.',
@@ -710,13 +753,13 @@ var OADocumentScope = class _OADocumentScope {
         operationDef.operation
         operationDef.operation
       );
       );
     }
     }
-    const fullPath = joinPath(this.pathPrefix, operationDef.path);
+    const fullPath = joinPath(this._pathPrefix, operationDef.path);
     const operation = structuredClone(operationDef.operation);
     const operation = structuredClone(operationDef.operation);
-    if (this.tags.length > 0) {
-      operation.tags = [...this.tags, ...operation.tags || []];
+    if (this._tags.length > 0) {
+      operation.tags = [...this._tags, ...operation.tags || []];
       operation.tags = [...new Set(operation.tags)];
       operation.tags = [...new Set(operation.tags)];
     }
     }
-    this.rootBuilder.defineOperation({
+    this._builder.defineOperation({
       ...operationDef,
       ...operationDef,
       path: fullPath,
       path: fullPath,
       operation
       operation
@@ -739,7 +782,7 @@ var OADocumentScope = class _OADocumentScope {
     if (options.pathPrefix !== void 0) {
     if (options.pathPrefix !== void 0) {
       if (!options.pathPrefix || typeof options.pathPrefix !== "string") {
       if (!options.pathPrefix || typeof options.pathPrefix !== "string") {
         throw new import_js_format4.InvalidArgumentError(
         throw new import_js_format4.InvalidArgumentError(
-          'Parameter "pathPrefix" must be a non-empty String, but %v was given.',
+          'Property "pathPrefix" must be a non-empty String, but %v was given.',
           options.pathPrefix
           options.pathPrefix
         );
         );
       }
       }
@@ -747,7 +790,7 @@ var OADocumentScope = class _OADocumentScope {
     if (options.tags !== void 0) {
     if (options.tags !== void 0) {
       if (!Array.isArray(options.tags)) {
       if (!Array.isArray(options.tags)) {
         throw new import_js_format4.InvalidArgumentError(
         throw new import_js_format4.InvalidArgumentError(
-          'Parameter "tags" must be an Array, but %v was given.',
+          'Property "tags" must be an Array, but %v was given.',
           options.tags
           options.tags
         );
         );
       }
       }
@@ -761,9 +804,9 @@ var OADocumentScope = class _OADocumentScope {
         }
         }
       });
       });
     }
     }
-    return new _OADocumentScope(this.rootBuilder, {
-      pathPrefix: joinPath(this.pathPrefix, options.pathPrefix),
-      tags: [...this.tags, ...options.tags || []]
+    return new _OADocumentScope(this._builder, {
+      pathPrefix: joinPath(this._pathPrefix, options.pathPrefix),
+      tags: [...this._tags, ...options.tags || []]
     });
     });
   }
   }
 };
 };

+ 7 - 10
src/oa-document-builder.js

@@ -1,5 +1,6 @@
 import {OADocumentScope} from './oa-document-scope.js';
 import {OADocumentScope} from './oa-document-scope.js';
 import {InvalidArgumentError} from '@e22m4u/js-format';
 import {InvalidArgumentError} from '@e22m4u/js-format';
+import {normalizePath} from './utils/normalize-path.js';
 import {OAOperationMethod} from './document-specification.js';
 import {OAOperationMethod} from './document-specification.js';
 import {isServiceContainer, Service} from '@e22m4u/js-service';
 import {isServiceContainer, Service} from '@e22m4u/js-service';
 import {OAComponentsSegment, OPENAPI_VERSION} from './constants.js';
 import {OAComponentsSegment, OPENAPI_VERSION} from './constants.js';
@@ -323,12 +324,6 @@ export class OADocumentBuilder extends Service {
         operationDef.path,
         operationDef.path,
       );
       );
     }
     }
-    if (operationDef.path[0] !== '/') {
-      throw new InvalidArgumentError(
-        'Property "path" must start with forward slash "/", but %v was given.',
-        operationDef.path,
-      );
-    }
     // method
     // method
     if (!operationDef.method || typeof operationDef.method !== 'string') {
     if (!operationDef.method || typeof operationDef.method !== 'string') {
       throw new InvalidArgumentError(
       throw new InvalidArgumentError(
@@ -353,14 +348,16 @@ export class OADocumentBuilder extends Service {
         operationDef.operation,
         operationDef.operation,
       );
       );
     }
     }
+    const normalizedPath = normalizePath(operationDef.path);
     if (!this._document.paths) {
     if (!this._document.paths) {
       this._document.paths = {};
       this._document.paths = {};
     }
     }
-    if (!this._document.paths[operationDef.path]) {
-      this._document.paths[operationDef.path] = {};
+    if (!this._document.paths[normalizedPath]) {
+      this._document.paths[normalizedPath] = {};
     }
     }
-    this._document.paths[operationDef.path][operationDef.method] =
-      structuredClone(operationDef.operation);
+    this._document.paths[normalizedPath][operationDef.method] = structuredClone(
+      operationDef.operation,
+    );
   }
   }
 
 
   /**
   /**

+ 17 - 2
src/oa-document-scope.d.ts

@@ -16,10 +16,25 @@ export type OADocumentScopeOptions = {
  */
  */
 export declare class OADocumentScope {
 export declare class OADocumentScope {
   /**
   /**
-   * @param rootBuilder
+   * @param builder
    * @param options
    * @param options
    */
    */
-  constructor(rootBuilder: OADocumentBuilder, options?: OADocumentScopeOptions);
+  constructor(builder: OADocumentBuilder, options?: OADocumentScopeOptions);
+
+  /**
+   * Get builder.
+   */
+  getBuilder(): OADocumentBuilder;
+
+  /**
+   * Get path prefix.
+   */
+  getPathPrefix(): string;
+
+  /**
+   * Get tags.
+   */
+  getTags(): string[];
 
 
   /**
   /**
    * Define operation.
    * Define operation.

+ 77 - 30
src/oa-document-scope.js

@@ -1,5 +1,5 @@
-import {joinPath} from './utils/index.js';
 import {InvalidArgumentError} from '@e22m4u/js-format';
 import {InvalidArgumentError} from '@e22m4u/js-format';
+import {joinPath, normalizePath} from './utils/index.js';
 import {OADocumentBuilder} from './oa-document-builder.js';
 import {OADocumentBuilder} from './oa-document-builder.js';
 import {OAOperationMethod} from './document-specification.js';
 import {OAOperationMethod} from './document-specification.js';
 
 
@@ -8,53 +8,104 @@ import {OAOperationMethod} from './document-specification.js';
  */
  */
 export class OADocumentScope {
 export class OADocumentScope {
   /**
   /**
-   * @param {object} rootBuilder
+   * Builder.
+   *
+   * @type {OADocumentBuilder}
+   */
+  _builder;
+
+  /**
+   * Path prefix.
+   *
+   * @type {string}
+   */
+  _pathPrefix;
+
+  /**
+   * Tags.
+   *
+   * @type {string[]}
+   */
+  _tags;
+
+  /**
+   * @param {object} builder
    * @param {object} [options]
    * @param {object} [options]
    */
    */
-  constructor(rootBuilder, options = {}) {
-    if (!(rootBuilder instanceof OADocumentBuilder)) {
+  constructor(builder, options = {}) {
+    // builder
+    if (!(builder instanceof OADocumentBuilder)) {
       throw new InvalidArgumentError(
       throw new InvalidArgumentError(
-        'Parameter "rootBuilder" must be an instance of OADocumentBuilder, ' +
+        'Parameter "builder" must be an instance of OADocumentBuilder, ' +
           'but %v was given.',
           'but %v was given.',
-        rootBuilder,
+        builder,
       );
       );
     }
     }
+    // options
     if (!options || typeof options !== 'object' || Array.isArray(options)) {
     if (!options || typeof options !== 'object' || Array.isArray(options)) {
       throw new InvalidArgumentError(
       throw new InvalidArgumentError(
         'Parameter "options" must be an Object, but %v was given.',
         'Parameter "options" must be an Object, but %v was given.',
         options,
         options,
       );
       );
     }
     }
+    // options.pathPrefix
     if (options.pathPrefix !== undefined) {
     if (options.pathPrefix !== undefined) {
       if (!options.pathPrefix || typeof options.pathPrefix !== 'string') {
       if (!options.pathPrefix || typeof options.pathPrefix !== 'string') {
         throw new InvalidArgumentError(
         throw new InvalidArgumentError(
-          'Parameter "pathPrefix" must be a non-empty String, ' +
+          'Property "pathPrefix" must be a non-empty String, ' +
             'but %v was given.',
             'but %v was given.',
           options.pathPrefix,
           options.pathPrefix,
         );
         );
       }
       }
     }
     }
+    // options.tags
     if (options.tags !== undefined) {
     if (options.tags !== undefined) {
       if (!Array.isArray(options.tags)) {
       if (!Array.isArray(options.tags)) {
         throw new InvalidArgumentError(
         throw new InvalidArgumentError(
-          'Parameter "tags" must be an Array, ' + 'but %v was given.',
+          'Property "tags" must be an Array, but %v was given.',
           options.tags,
           options.tags,
         );
         );
       }
       }
       options.tags.forEach((tag, index) => {
       options.tags.forEach((tag, index) => {
         if (!tag || typeof tag !== 'string') {
         if (!tag || typeof tag !== 'string') {
           throw new InvalidArgumentError(
           throw new InvalidArgumentError(
-            'Element "tags[%d]" must be a non-empty String, ' +
-              'but %v was given.',
+            'Element "tags[%d]" must be a non-empty String, but %v was given.',
             index,
             index,
             tag,
             tag,
           );
           );
         }
         }
       });
       });
     }
     }
-    this.rootBuilder = rootBuilder;
-    this.pathPrefix = options.pathPrefix || '/';
-    this.tags = options.tags || [];
+    this._builder = builder;
+    this._pathPrefix = normalizePath(options.pathPrefix || '/');
+    this._tags = options.tags || [];
+  }
+
+  /**
+   * Get builder.
+   *
+   * @returns {OADocumentBuilder}
+   */
+  getBuilder() {
+    return this._builder;
+  }
+
+  /**
+   * Get path prefix.
+   *
+   * @returns {string}
+   */
+  getPathPrefix() {
+    return this._pathPrefix;
+  }
+
+  /**
+   * Get tags.
+   *
+   * @returns {string[]}
+   */
+  getTags() {
+    return [...this._tags];
   }
   }
 
 
   /**
   /**
@@ -81,12 +132,6 @@ export class OADocumentScope {
         operationDef.path,
         operationDef.path,
       );
       );
     }
     }
-    if (operationDef.path[0] !== '/') {
-      throw new InvalidArgumentError(
-        'Property "path" must start with forward slash "/", but %v was given.',
-        operationDef.path,
-      );
-    }
     // method
     // method
     if (!operationDef.method || typeof operationDef.method !== 'string') {
     if (!operationDef.method || typeof operationDef.method !== 'string') {
       throw new InvalidArgumentError(
       throw new InvalidArgumentError(
@@ -112,20 +157,20 @@ export class OADocumentScope {
       );
       );
     }
     }
     // склеивание пути
     // склеивание пути
-    const fullPath = joinPath(this.pathPrefix, operationDef.path);
+    const fullPath = joinPath(this._pathPrefix, operationDef.path);
     // создание копии схемы операции
     // создание копии схемы операции
     // чтобы избежать мутацию аргумента
     // чтобы избежать мутацию аргумента
     const operation = structuredClone(operationDef.operation);
     const operation = structuredClone(operationDef.operation);
     // объединение тегов текущей области
     // объединение тегов текущей области
     // с тегами текущей операции и удаление
     // с тегами текущей операции и удаление
     // дубликатов
     // дубликатов
-    if (this.tags.length > 0) {
-      operation.tags = [...this.tags, ...(operation.tags || [])];
+    if (this._tags.length > 0) {
+      operation.tags = [...this._tags, ...(operation.tags || [])];
       operation.tags = [...new Set(operation.tags)];
       operation.tags = [...new Set(operation.tags)];
     }
     }
     // регистрация операции в родительском
     // регистрация операции в родительском
     // экземпляре сборщика документа
     // экземпляре сборщика документа
-    this.rootBuilder.defineOperation({
+    this._builder.defineOperation({
       ...operationDef,
       ...operationDef,
       path: fullPath,
       path: fullPath,
       operation,
       operation,
@@ -140,42 +185,44 @@ export class OADocumentScope {
    * @returns {OADocumentScope}
    * @returns {OADocumentScope}
    */
    */
   createScope(options = {}) {
   createScope(options = {}) {
+    // options
     if (!options || typeof options !== 'object' || Array.isArray(options)) {
     if (!options || typeof options !== 'object' || Array.isArray(options)) {
       throw new InvalidArgumentError(
       throw new InvalidArgumentError(
         'Parameter "options" must be an Object, but %v was given.',
         'Parameter "options" must be an Object, but %v was given.',
         options,
         options,
       );
       );
     }
     }
+    // options.pathPrefix
     if (options.pathPrefix !== undefined) {
     if (options.pathPrefix !== undefined) {
       if (!options.pathPrefix || typeof options.pathPrefix !== 'string') {
       if (!options.pathPrefix || typeof options.pathPrefix !== 'string') {
         throw new InvalidArgumentError(
         throw new InvalidArgumentError(
-          'Parameter "pathPrefix" must be a non-empty String, ' +
+          'Property "pathPrefix" must be a non-empty String, ' +
             'but %v was given.',
             'but %v was given.',
           options.pathPrefix,
           options.pathPrefix,
         );
         );
       }
       }
     }
     }
+    // options.tags
     if (options.tags !== undefined) {
     if (options.tags !== undefined) {
       if (!Array.isArray(options.tags)) {
       if (!Array.isArray(options.tags)) {
         throw new InvalidArgumentError(
         throw new InvalidArgumentError(
-          'Parameter "tags" must be an Array, ' + 'but %v was given.',
+          'Property "tags" must be an Array, but %v was given.',
           options.tags,
           options.tags,
         );
         );
       }
       }
       options.tags.forEach((tag, index) => {
       options.tags.forEach((tag, index) => {
         if (!tag || typeof tag !== 'string') {
         if (!tag || typeof tag !== 'string') {
           throw new InvalidArgumentError(
           throw new InvalidArgumentError(
-            'Element "tags[%d]" must be a non-empty String, ' +
-              'but %v was given.',
+            'Element "tags[%d]" must be a non-empty String, but %v was given.',
             index,
             index,
             tag,
             tag,
           );
           );
         }
         }
       });
       });
     }
     }
-    return new OADocumentScope(this.rootBuilder, {
-      pathPrefix: joinPath(this.pathPrefix, options.pathPrefix),
-      tags: [...this.tags, ...(options.tags || [])],
+    return new OADocumentScope(this._builder, {
+      pathPrefix: joinPath(this._pathPrefix, options.pathPrefix),
+      tags: [...this._tags, ...(options.tags || [])],
     });
     });
   }
   }
 }
 }

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

@@ -1,2 +1,3 @@
 export * from './oa-ref.js';
 export * from './oa-ref.js';
 export * from './join-path.js';
 export * from './join-path.js';
+export * from './normalize-path.js';

+ 1 - 0
src/utils/index.js

@@ -1,2 +1,3 @@
 export * from './oa-ref.js';
 export * from './oa-ref.js';
 export * from './join-path.js';
 export * from './join-path.js';
+export * from './normalize-path.js';

+ 2 - 2
src/utils/join-path.js

@@ -7,8 +7,8 @@
 export function joinPath(...segments) {
 export function joinPath(...segments) {
   const path = segments
   const path = segments
     .filter(seg => seg != undefined)
     .filter(seg => seg != undefined)
-    .map(seg => String(seg).replace(/(^\/|\/$)/g, ''))
+    .map(seg => String(seg).replace(/(^\/+|\/+$)/g, ''))
     .filter(Boolean)
     .filter(Boolean)
     .join('/');
     .join('/');
-  return '/' + path;
+  return ('/' + path).replace(/\/+/g, '/');
 }
 }

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

@@ -66,4 +66,9 @@ describe('joinPath', function () {
     const result = joinPath(-10, 0, 10);
     const result = joinPath(-10, 0, 10);
     expect(result).to.be.eq('/-10/0/10');
     expect(result).to.be.eq('/-10/0/10');
   });
   });
+
+  it('should remove duplicate slashes', function () {
+    const result = joinPath('/', '//foo//', '///bar///');
+    expect(result).to.be.eq('/foo/bar');
+  });
 });
 });

+ 12 - 0
src/utils/normalize-path.d.ts

@@ -0,0 +1,12 @@
+/**
+ * Normalize path.
+ *
+ * Заменяет любые повторяющиеся слеши на один.
+ * Удаляет пробельные символы в начале и конце.
+ * Удаляет слеш в конце строки.
+ * Гарантирует слеш в начале строки (по умолчанию).
+ *
+ * @param value
+ * @param noStartingSlash
+ */
+export function normalizePath(value: string, noStartingSlash?: boolean): string;

+ 22 - 0
src/utils/normalize-path.js

@@ -0,0 +1,22 @@
+/**
+ * Normalize path.
+ *
+ * Заменяет любые повторяющиеся слеши на один.
+ * Удаляет пробельные символы в начале и конце.
+ * Удаляет слеш в конце строки.
+ * Гарантирует слеш в начале строки (по умолчанию).
+ *
+ * @param {string} value
+ * @param {boolean} [noStartingSlash]
+ * @returns {string}
+ */
+export function normalizePath(value, noStartingSlash = false) {
+  if (typeof value !== 'string') {
+    return '/';
+  }
+  const res = value
+    .trim()
+    .replace(/\/+/g, '/')
+    .replace(/(^\/|\/$)/g, '');
+  return noStartingSlash ? res : '/' + res;
+}

+ 56 - 0
src/utils/normalize-path.spec.js

@@ -0,0 +1,56 @@
+import {expect} from 'chai';
+import {normalizePath} from './normalize-path.js';
+
+describe('normalizePath', function () {
+  describe('input validation', function () {
+    it('should return a root path "/" if value is null', function () {
+      expect(normalizePath(null)).to.equal('/');
+    });
+
+    it('should return a root path "/" if value is undefined', function () {
+      expect(normalizePath(undefined)).to.equal('/');
+    });
+
+    it('should return a root path "/" if value is a number', function () {
+      expect(normalizePath(123)).to.equal('/');
+    });
+
+    it('should return a root path "/" if value is an object', function () {
+      expect(normalizePath({})).to.equal('/');
+    });
+  });
+
+  describe('path normalization', function () {
+    it('should replace multiple slashes with a single slash', function () {
+      expect(normalizePath('//api///users//')).to.equal('/api/users');
+    });
+
+    it('should trim a given string but preserve whitespace characters', function () {
+      expect(normalizePath(' /my folder/ ')).to.equal('/my folder');
+      expect(normalizePath('path\twith\ntabs')).to.equal('/path\twith\ntabs');
+    });
+
+    it('should remove leading and trailing slashes before applying the final format', function () {
+      expect(normalizePath('/foo/bar/')).to.equal('/foo/bar');
+    });
+
+    it('should handle an empty string by returning "/" by default', function () {
+      expect(normalizePath('')).to.equal('/');
+    });
+  });
+
+  describe('the "noStartingSlash" option', function () {
+    it('should always prepend a leading slash when the option is false', function () {
+      expect(normalizePath('foo/bar', false)).to.equal('/foo/bar');
+    });
+
+    it('should not prepend a leading slash when the option is true', function () {
+      expect(normalizePath('/foo/bar/', true)).to.equal('foo/bar');
+    });
+
+    it('should return an empty string if the input results in an empty path', function () {
+      expect(normalizePath('', true)).to.equal('');
+      expect(normalizePath('///', true)).to.equal('');
+    });
+  });
+});