From 32230781b0fc5883bb29d39cb566c95d9a0c6083 Mon Sep 17 00:00:00 2001 From: antoniopresto Date: Thu, 30 Nov 2023 22:44:36 -0300 Subject: [PATCH] add routeUtils --- package.json | 2 +- packages/accounts/package.json | 2 +- packages/babel-plugins/package.json | 2 +- packages/boilerplate/package.json | 2 +- packages/deepstate/package.json | 2 +- packages/entity/package.json | 2 +- packages/helpers/package.json | 2 +- packages/logstorm/package.json | 2 +- packages/mongo/package.json | 2 +- packages/plugin-engine/package.json | 2 +- packages/powership/package.json | 2 +- packages/runmate/package.json | 2 +- packages/schema/package.json | 2 +- packages/server/package.json | 2 +- packages/server/src/routeMatch.ts | 4 +- packages/transporter/package.json | 2 +- packages/utils/package.json | 7 +- .../utils/src/__tests__/routeUtils.spec.ts | 86 +++++++ packages/utils/src/index.ts | 1 + packages/utils/src/routeUtils.ts | 232 ++++++++++++++++++ pnpm-lock.yaml | 25 +- 21 files changed, 363 insertions(+), 22 deletions(-) create mode 100644 packages/utils/src/__tests__/routeUtils.spec.ts create mode 100644 packages/utils/src/routeUtils.ts diff --git a/package.json b/package.json index 8bf450df..11d44099 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "powership", - "version": "3.1.7", + "version": "3.1.8", "private": true, "scripts": { "pack": "run-s pack:*", diff --git a/packages/accounts/package.json b/packages/accounts/package.json index 67a864bf..246f3179 100644 --- a/packages/accounts/package.json +++ b/packages/accounts/package.json @@ -1,6 +1,6 @@ { "name": "@powership/accounts", - "version": "3.1.7", + "version": "3.1.8", "description": "Powership accounts", "#type": "module", "main": "./out/index.cjs", diff --git a/packages/babel-plugins/package.json b/packages/babel-plugins/package.json index 3db560f4..3a945c03 100644 --- a/packages/babel-plugins/package.json +++ b/packages/babel-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@powership/babel-plugins", - "version": "3.1.7", + "version": "3.1.8", "main": "out", "sideEffects": false, "typings": "out", diff --git a/packages/boilerplate/package.json b/packages/boilerplate/package.json index 86ca4945..d5bec179 100644 --- a/packages/boilerplate/package.json +++ b/packages/boilerplate/package.json @@ -1,6 +1,6 @@ { "name": "@powership/boilerplate", - "version": "3.1.7", + "version": "3.1.8", "author": "antoniopresto ", "sideEffects": false, "#type": "module", diff --git a/packages/deepstate/package.json b/packages/deepstate/package.json index 0dc5cdfa..d330eb4e 100644 --- a/packages/deepstate/package.json +++ b/packages/deepstate/package.json @@ -1,6 +1,6 @@ { "name": "@powership/deepstate", - "version": "3.1.7", + "version": "3.1.8", "main": "out/index.js", "module": "out/module/index.mjs", "sideEffects": false, diff --git a/packages/entity/package.json b/packages/entity/package.json index c306dbe1..6102f0d6 100644 --- a/packages/entity/package.json +++ b/packages/entity/package.json @@ -1,6 +1,6 @@ { "name": "@powership/entity", - "version": "3.1.7", + "version": "3.1.8", "#type": "module", "main": "./out/index.cjs", "module": "./out/module/index.mjs", diff --git a/packages/helpers/package.json b/packages/helpers/package.json index b656aa40..9435894b 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@powership/helpers", - "version": "3.1.7", + "version": "3.1.8", "#type": "module", "main": "./out/index.cjs", "module": "./out/module/index.mjs", diff --git a/packages/logstorm/package.json b/packages/logstorm/package.json index 32facaf9..da3bd63c 100644 --- a/packages/logstorm/package.json +++ b/packages/logstorm/package.json @@ -1,6 +1,6 @@ { "name": "logstorm", - "version": "3.1.7", + "version": "3.1.8", "typings": "out", "author": "antoniopresto ", "#type": "module", diff --git a/packages/mongo/package.json b/packages/mongo/package.json index 3810be7c..f9774b9e 100644 --- a/packages/mongo/package.json +++ b/packages/mongo/package.json @@ -1,6 +1,6 @@ { "name": "@powership/mongo", - "version": "3.1.7", + "version": "3.1.8", "#type": "module", "main": "./out/index.cjs", "module": "./out/module/index.mjs", diff --git a/packages/plugin-engine/package.json b/packages/plugin-engine/package.json index aac15252..13fec6fc 100644 --- a/packages/plugin-engine/package.json +++ b/packages/plugin-engine/package.json @@ -1,6 +1,6 @@ { "name": "plugin-engine", - "version": "3.1.7", + "version": "3.1.8", "#type": "module", "main": "./out/index.cjs", "module": "./out/module/index.mjs", diff --git a/packages/powership/package.json b/packages/powership/package.json index 2b2f4224..0bded444 100644 --- a/packages/powership/package.json +++ b/packages/powership/package.json @@ -1,6 +1,6 @@ { "name": "powership", - "version": "3.1.7", + "version": "3.1.8", "author": "antoniopresto ", "#type": "module", "main": "./out/index.cjs", diff --git a/packages/runmate/package.json b/packages/runmate/package.json index f02e0156..64aad587 100644 --- a/packages/runmate/package.json +++ b/packages/runmate/package.json @@ -1,6 +1,6 @@ { "name": "runmate", - "version": "3.1.7", + "version": "3.1.8", "typings": "out", "author": "antoniopresto ", "license": "MIT", diff --git a/packages/schema/package.json b/packages/schema/package.json index ef65c1f2..0434212b 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@powership/schema", - "version": "3.1.7", + "version": "3.1.8", "#type": "module", "main": "./out/index.cjs", "module": "./out/module/index.mjs", diff --git a/packages/server/package.json b/packages/server/package.json index 29da4a17..05fc6844 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@powership/server", - "version": "3.1.7", + "version": "3.1.8", "#type": "module", "main": "./out/index.cjs", "module": "./out/module/index.mjs", diff --git a/packages/server/src/routeMatch.ts b/packages/server/src/routeMatch.ts index 02f4b412..ccd88076 100644 --- a/packages/server/src/routeMatch.ts +++ b/packages/server/src/routeMatch.ts @@ -52,7 +52,7 @@ export type GetRouteParams = IsKnown extends 0 } extends infer Parsed ? { // removing ending "?" simbol - [K in ExcludeOptionalSimbol]: Parsed extends { + [K in ExcludeOptionalSymbol]: Parsed extends { [KK in `${K}?`]: any; } ? AlphaNumeric | undefined @@ -60,7 +60,7 @@ export type GetRouteParams = IsKnown extends 0 } : never; -type ExcludeOptionalSimbol = Extract< +type ExcludeOptionalSymbol = Extract< T extends `${infer Value}?` ? Value : T, string >; diff --git a/packages/transporter/package.json b/packages/transporter/package.json index a64d7a92..324c917b 100644 --- a/packages/transporter/package.json +++ b/packages/transporter/package.json @@ -1,6 +1,6 @@ { "name": "@powership/transporter", - "version": "3.1.7", + "version": "3.1.8", "#type": "module", "main": "./out/index.cjs", "module": "./out/module/index.mjs", diff --git a/packages/utils/package.json b/packages/utils/package.json index bac95c14..e06efbdb 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@powership/utils", - "version": "3.1.7", + "version": "3.1.8", "typings": "out", "author": "antoniopresto ", "license": "MIT", @@ -62,7 +62,9 @@ "prettier": "2.8.8", "slugify": "1.6.5", "ts-toolbelt": "9.6.0", - "ulid": "2.3.0" + "ulid": "2.3.0", + "qs": "6.11.2", + "url-pattern": "1.0.3" }, "devDependencies": { "@powership/boilerplate": "workspace:*", @@ -72,6 +74,7 @@ "@babel/preset-typescript": "7.18.6", "@powership/babel-plugins": "workspace:*", "@types/big.js": "6.1.6", + "@types/qs": "6.9.10", "@types/deep-diff": "^1.0.2", "@types/ejson": "2.2.0", "@types/fs-extra": "9.0.13", diff --git a/packages/utils/src/__tests__/routeUtils.spec.ts b/packages/utils/src/__tests__/routeUtils.spec.ts new file mode 100644 index 00000000..c4d831bd --- /dev/null +++ b/packages/utils/src/__tests__/routeUtils.spec.ts @@ -0,0 +1,86 @@ +import { RouteUtils } from '../routeUtils'; // Adjust the import path as necessary + +describe('RouteUtils', () => { + describe('normalizePath', () => { + it('should remove starting and trailing slashes', () => { + expect(RouteUtils.normalizePath('/a/b/c/')).toBe('a/b/c'); + }); + + it('should remove double slashes', () => { + expect(RouteUtils.normalizePath('a//b//c')).toBe('a/b/c'); + }); + }); + + describe('joinPaths', () => { + it('should join multiple path segments', () => { + expect( + RouteUtils.joinPaths('/a/', 'b', null, undefined, '', '/c', 'd/') + ).toBe('a/b/c/d'); + }); + }); + + describe('parseQueryString', () => { + it('should parse query string into an object', () => { + expect(RouteUtils.parseQueryString('?key1=value1&key2=value2')).toEqual({ + key1: 'value1', + key2: 'value2', + }); + }); + }); + + describe('stringifyQueryString', () => { + it('should stringify an object into a query string', () => { + expect( + RouteUtils.stringifyQueryString({ key1: 'value1', key2: 'value2' }) + ).toBe('key1=value1&key2=value2'); + }); + }); + + describe('resortQueryString', () => { + it('should resort query string', () => { + expect(RouteUtils.resortQueryString('?b=2&a=1')).toBe('a=1&b=2'); + }); + }); + + describe('parseURL', () => { + it('should parse URL into its components', () => { + const parsed = RouteUtils.parseURL('/test?query=string#hash'); + + expect(parsed).toEqual({ + pathname: '/test', + search: '?query=string', + hash: '#hash', + route: '/test?query=string#hash', + id: 'test^query=string^hash', + href: 'http://localhost/test?query=string#hash', + domain: 'http://localhost', + isAbsolutePath: false, + protocol: 'http:', + host: 'localhost', + hostname: 'localhost', + port: '', + }); + }); + }); + + describe('isSamePathname', () => { + it('should return true for URLs with the same pathname', () => { + expect( + RouteUtils.isSamePathname('http://localhost/a', 'http://example.com/a') + ).toBe(true); + }); + + it('should return false for URLs with different pathnames', () => { + expect( + RouteUtils.isSamePathname('http://localhost/a', 'http://example.com/b') + ).toBe(false); + }); + }); + + describe('createRouteMatcher', () => { + it('should create a route matcher and match a given route', () => { + const matcher = RouteUtils.createRouteMatcher('/test/:id'); + expect(matcher.match('/test/123')).toEqual({ id: '123' }); + }); + }); +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 2ba5a3a3..cd08b6ed 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -76,6 +76,7 @@ export * from './objectEntries'; export * from './immer'; export * from './MicroState'; export * from './skipper'; +export * from './routeUtils'; // @only-server export * from './logLevels'; diff --git a/packages/utils/src/routeUtils.ts b/packages/utils/src/routeUtils.ts new file mode 100644 index 00000000..b7f3c570 --- /dev/null +++ b/packages/utils/src/routeUtils.ts @@ -0,0 +1,232 @@ +import qs from 'qs'; +import UrlPattern from 'url-pattern'; + +import { IsKnown } from './typings'; + +/** + * Utilities for handling URL and route-related operations. + */ +export class RouteUtils { + /** + * Removes starting and trailing slashes from a path string. + * @param path The path to be normalized. + * @returns The path with starting and trailing slashes removed. + */ + static normalizePath(path: string): string { + return path.replace(/^\//, '').replace(/\/$/, '').replace(/\/\//gm, '/'); + } + + /** + * Joins multiple path segments, normalizing slashes. + * @param segments The path segments to join. + * @returns The joined path. + */ + static joinPaths(...segments: (string | null | undefined)[]): string { + return segments + .filter(Boolean) + .map((segment) => RouteUtils.normalizePath(segment as string)) + .join('/'); + } + + /** + * Parses a query string into an object. + * @param queryString The query string to parse. + * @returns The parsed query string object. + */ + static parseQueryString(queryString: string) { + return qs.parse(queryString.replace(/^\?/, '')); + } + + /** + * Stringifies an object into a query string. + * @param queryObject The object to be stringified. + * @returns The query string. + */ + static stringifyQueryString(queryObject: Record) { + return qs.stringify(queryObject, { + sort: (a, b) => (a > b ? 1 : -1), + }); + } + + /** + * Resorts a query string. + * @param queryString The query string to resort. + * @returns The resorted query string. + */ + static resortQueryString(queryString = ''): string { + return RouteUtils.stringifyQueryString( + RouteUtils.parseQueryString(queryString) + ); + } + + /** + * Parses a URL or pathname into its components. + * @param urlOrPathname The URL or pathname to parse. + * @param defaultDomain The default domain to use if none is provided. + * @returns The parsed URL components. + */ + static parseURL(urlOrPathname: string, defaultDomain = ''): ParsedURL { + let isAbsolutePath = urlOrPathname.startsWith('/'); + let domain = RouteUtils.getDomainPart(defaultDomain || urlOrPathname); + + if (isAbsolutePath) { + isAbsolutePath = false; + urlOrPathname = `${domain}/${RouteUtils.normalizePath(urlOrPathname)}`; + } + + const hasProtocol = /^(?:([^:\/?#]+):\/\/)/.test(urlOrPathname); + + if (!hasProtocol && !isAbsolutePath) { + throw new Error( + `Expected input to be a full URL or an absolute path, received "${urlOrPathname}"` + ); + } + + const url = new URL(urlOrPathname, hasProtocol ? urlOrPathname : domain); + const { pathname, search, hash } = url; + + const id = [ + RouteUtils.normalizePath(pathname), + RouteUtils.resortQueryString(search), + hash.replace(/^#/, ''), + ].join('^'); + + const parsedURL: ParsedURL = { + domain, + href: urlOrPathname, + pathname, + search, + hash, + route: `${pathname}${search}${hash ? `${hash}` : ''}`, + isAbsolutePath: true, + id, + }; + + if (isAbsolutePath) return parsedURL; + + return { + ...parsedURL, + protocol: url.protocol, + host: url.host, + hostname: url.hostname, + port: url.port, + isAbsolutePath: false, + }; + } + + /** + * Compares if two URLs have the same pathname. + * @param firstURL The first URL to compare. + * @param secondURL The second URL to compare. + * @returns `true` if the pathnames are the same, `false` otherwise. + */ + static isSamePathname(firstURL: string, secondURL: string): boolean { + return ( + RouteUtils.parseURL(firstURL).pathname === + RouteUtils.parseURL(secondURL).pathname + ); + } + + /** + * Creates a route matcher for a specified path. + * @param path The path pattern to create a matcher for. + * @returns The route matcher. + */ + static createRouteMatcher( + path: Path + ): RouteMatcher { + let formattedPath = path + .replace(/(\/:?)([^?/:]*)\?/g, '($1$2)') + .concat(path.endsWith('/') ? '' : `(/)`); + + return new UrlPattern(formattedPath, {}); + } + + // Default domain value + static DEFAULT_DOMAIN = { value: 'http://localhost' }; + + // Private helper methods + private static checkHasProtocol(url = ''): boolean { + return /:\/\//.test(url); + } + + private static getDefaultDomain(): string { + if ( + typeof window === 'object' && + RouteUtils.checkHasProtocol(window.location.href) + ) { + return RouteUtils.getDomainPart(window.location.href); + } + return RouteUtils.DEFAULT_DOMAIN.value; + } + + private static getDomainPart(url: string): string { + if (!RouteUtils.checkHasProtocol(url)) return RouteUtils.getDefaultDomain(); + let parts = (url.split('?')[0] || '').split('/').slice(0, 3); + return parts.join('/') || RouteUtils.getDefaultDomain(); + } +} + +// Types related to routing +export type ParsedURL = { + pathname: string; + search: string; + hash: string; + route: string; + id: string; + href: string; + domain: string; +} & ( + | { + isAbsolutePath: true; + } + | { + isAbsolutePath: false; + protocol: string; + host: string; + hostname: string; + port: string; + } +); + +export interface RouteMatcher { + stringify: (params: GetRouteParams) => string; + match(route: string): GetRouteParams | null; +} + +export type AlphaNumeric = string | number; + +// extract route params from a string literal +export type ExtractRouteParams = + Path extends `:${infer Param}/${infer Rest}` + ? Param | ExtractRouteParams + : Path extends `:${infer Param}` + ? Param + : Path extends `${string}:${infer Rest}` + ? ExtractRouteParams<`:${Rest}`> + : never; + +// used to infer if a route needs a params object +export type GetRouteParams = IsKnown extends 0 + ? {} + : [ExtractRouteParams] extends [never] + ? {} + : { + [K in ExtractRouteParams]: K extends `${string}?` + ? AlphaNumeric | undefined + : AlphaNumeric; + } extends infer Parsed + ? { + // removing ending "?" simbol + [K in ExcludeOptionalSymbol]: Parsed extends { + [KK in `${K}?`]: any; + } + ? AlphaNumeric | undefined + : AlphaNumeric; + } + : never; + +type ExcludeOptionalSymbol = Extract< + T extends `${infer Value}?` ? Value : T, + string +>; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95dc67b7..cb3cfea8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1620,6 +1620,9 @@ importers: prettier: specifier: 2.8.8 version: 2.8.8 + qs: + specifier: 6.11.2 + version: 6.11.2 slugify: specifier: 1.6.5 version: 1.6.5 @@ -1629,6 +1632,9 @@ importers: ulid: specifier: 2.3.0 version: 2.3.0 + url-pattern: + specifier: 1.0.3 + version: 1.0.3 devDependencies: '@babel/cli': specifier: 7.19.3 @@ -1678,6 +1684,9 @@ importers: '@types/prettier': specifier: 2.7.1 version: 2.7.1 + '@types/qs': + specifier: 6.9.10 + version: 6.9.10 '@typescript-eslint/eslint-plugin': specifier: 5.39.0 version: 5.39.0(@typescript-eslint/parser@5.39.0)(eslint@8.25.0)(typescript@4.9.3) @@ -3797,7 +3806,7 @@ packages: resolution: {integrity: sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg==} dependencies: '@types/node': 16.18.3 - '@types/qs': 6.9.7 + '@types/qs': 6.9.10 '@types/range-parser': 1.2.4 '@types/send': 0.17.1 dev: true @@ -3927,6 +3936,10 @@ packages: resolution: {integrity: sha512-ZREFYlpUmPQJ0esjxoG1fMvB2HNaD3z+mjqdSosZvd3RalncI9NEur73P8ZJz4YQdL64CmV1w0RuqoRUlhQRBw==} dev: false + /@types/qs@6.9.10: + resolution: {integrity: sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw==} + dev: true + /@types/qs@6.9.7: resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==} dev: true @@ -6355,7 +6368,7 @@ packages: dezalgo: 1.0.4 hexoid: 1.0.0 once: 1.4.0 - qs: 6.11.0 + qs: 6.11.2 dev: true /forwarded@0.2.0: @@ -8626,6 +8639,12 @@ packages: dependencies: side-channel: 1.0.4 + /qs@6.11.2: + resolution: {integrity: sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==} + engines: {node: '>=0.6'} + dependencies: + side-channel: 1.0.4 + /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -9155,7 +9174,7 @@ packages: formidable: 2.1.2 methods: 1.1.2 mime: 2.6.0 - qs: 6.11.0 + qs: 6.11.2 semver: 7.3.8 transitivePeerDependencies: - supports-color