aboutsummaryrefslogtreecommitdiff
path: root/packages/taler-util
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2023-04-03 12:12:35 -0300
committerSebastian <sebasjm@gmail.com>2023-04-03 12:12:35 -0300
commitdefdfd769749354ec3aba9436a3bcb5cd39bdd83 (patch)
treed41041fcbaf0b196a0c09fd101985abf66c5fb8d /packages/taler-util
parentce53dd8b851c4c4e18f646cb52c97fb1f1414cc1 (diff)
taleruri sync with the spec
Diffstat (limited to 'packages/taler-util')
-rw-r--r--packages/taler-util/src/taleruri.ts555
1 files changed, 438 insertions, 117 deletions
diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts
index 4d55d4c98..e641f1d27 100644
--- a/packages/taler-util/src/taleruri.ts
+++ b/packages/taler-util/src/taleruri.ts
@@ -14,11 +14,24 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { BackupRecovery } from "./backup-types.js";
import { canonicalizeBaseUrl } from "./helpers.js";
import { URLSearchParams, URL } from "./url.js";
+export type TalerUri =
+ | PayUriResult
+ | PayTemplateUriResult
+ | DevExperimentUri
+ | PayPullUriResult
+ | PayPushUriResult
+ | BackupRestoreUri
+ | RefundUriResult
+ | TipUriResult
+ | WithdrawUriResult
+ | ExchangeUri
+ | AuditorUri;
+
export interface PayUriResult {
+ type: TalerUriAction.Pay;
merchantBaseUrl: string;
orderId: string;
sessionId: string;
@@ -27,40 +40,68 @@ export interface PayUriResult {
}
export interface PayTemplateUriResult {
+ type: TalerUriAction.PayTemplate;
merchantBaseUrl: string;
templateId: string;
templateParams: Record<string, string>;
}
export interface WithdrawUriResult {
+ type: TalerUriAction.Withdraw;
bankIntegrationApiBaseUrl: string;
withdrawalOperationId: string;
}
export interface RefundUriResult {
+ type: TalerUriAction.Refund;
merchantBaseUrl: string;
orderId: string;
}
export interface TipUriResult {
- merchantTipId: string;
+ type: TalerUriAction.Tip;
merchantBaseUrl: string;
+ merchantTipId: string;
+}
+
+export interface ExchangeUri {
+ type: TalerUriAction.Exchange;
+ exchangeBaseUrl: string;
+ exchangePub: string;
+}
+
+export interface AuditorUri {
+ type: TalerUriAction.Auditor;
+ auditorBaseUrl: string;
+ auditorPub: string;
}
export interface PayPushUriResult {
+ type: TalerUriAction.PayPush;
exchangeBaseUrl: string;
contractPriv: string;
}
export interface PayPullUriResult {
+ type: TalerUriAction.PayPull;
exchangeBaseUrl: string;
contractPriv: string;
}
export interface DevExperimentUri {
+ type: TalerUriAction.DevExperiment;
devExperimentId: string;
}
+export interface BackupRestoreUri {
+ type: TalerUriAction.Restore;
+ walletRootPriv: string;
+ providers: {
+ name: string;
+ url: string;
+ }[];
+}
+
/**
* Parse a taler[+http]://withdraw URI.
* Return undefined if not passed a valid URI.
@@ -89,11 +130,15 @@ export function parseWithdrawUri(s: string): WithdrawUriResult | undefined {
const p = [host, ...pathSegments].join("/");
return {
+ type: TalerUriAction.Withdraw,
bankIntegrationApiBaseUrl: canonicalizeBaseUrl(`${pi.innerProto}://${p}/`),
withdrawalOperationId: withdrawId,
};
}
+/**
+ * @deprecated use TalerUriAction
+ */
export enum TalerUriType {
TalerPay = "taler-pay",
TalerTemplate = "taler-template",
@@ -112,15 +157,30 @@ const talerActionPayPull = "pay-pull";
const talerActionPayPush = "pay-push";
const talerActionPayTemplate = "pay-template";
+export enum TalerUriAction {
+ Pay = "pay",
+ Withdraw = "withdraw",
+ Refund = "refund",
+ Tip = "tip",
+ PayPull = "pay-pull",
+ PayPush = "pay-push",
+ PayTemplate = "pay-template",
+ Exchange = "exchange",
+ Auditor = "auditor",
+ Restore = "restore",
+ DevExperiment = "dev-experiment",
+}
+
/**
* Classify a taler:// URI.
+ * @deprecated use parseTalerUri
*/
export function classifyTalerUri(s: string): TalerUriType {
const sl = s.toLowerCase();
- if (sl.startsWith("taler://recovery/")) {
+ if (sl.startsWith("taler://restore/")) {
return TalerUriType.TalerRecovery;
}
- if (sl.startsWith("taler+http://recovery/")) {
+ if (sl.startsWith("taler+http://restore/")) {
return TalerUriType.TalerRecovery;
}
if (sl.startsWith("taler://pay/")) {
@@ -197,6 +257,71 @@ function parseProtoInfo(
}
}
+type Parser = (s: string) => TalerUri | undefined;
+const parsers: { [A in TalerUriAction]: Parser } = {
+ [TalerUriAction.Pay]: parsePayUri,
+ [TalerUriAction.PayPull]: parsePayPullUri,
+ [TalerUriAction.PayPush]: parsePayPushUri,
+ [TalerUriAction.PayTemplate]: parsePayTemplateUri,
+ [TalerUriAction.Restore]: parseRestoreUri,
+ [TalerUriAction.Refund]: parseRefundUri,
+ [TalerUriAction.Tip]: parseTipUri,
+ [TalerUriAction.Withdraw]: parseWithdrawUri,
+ [TalerUriAction.DevExperiment]: parseDevExperimentUri,
+ [TalerUriAction.Exchange]: parseExchangeUri,
+ [TalerUriAction.Auditor]: parseAuditorUri,
+};
+
+export function parseTalerUri(string: string): TalerUri | undefined {
+ const https = string.startsWith("taler://");
+ const http = string.startsWith("taler+http://");
+ if (!https && !http) return undefined;
+ const actionStart = https ? 8 : 13;
+ const actionEnd = string.indexOf("/", actionStart + 1);
+ const action = string.substring(actionStart, actionEnd);
+ const found = Object.values(TalerUriAction).find((x) => x === action);
+ if (!found) return undefined;
+ return parsers[found](string);
+}
+
+export function stringifyTalerUri(uri: TalerUri): string {
+ switch (uri.type) {
+ case TalerUriAction.DevExperiment: {
+ return stringifyDevExperimentUri(uri);
+ }
+ case TalerUriAction.Pay: {
+ return stringifyPayUri(uri);
+ }
+ case TalerUriAction.PayPull: {
+ return stringifyPayPullUri(uri);
+ }
+ case TalerUriAction.PayPush: {
+ return stringifyPayPushUri(uri);
+ }
+ case TalerUriAction.PayTemplate: {
+ return stringifyPayTemplateUri(uri);
+ }
+ case TalerUriAction.Restore: {
+ return stringifyRestoreUri(uri);
+ }
+ case TalerUriAction.Refund: {
+ return stringifyRefundUri(uri);
+ }
+ case TalerUriAction.Tip: {
+ return stringifyTipUri(uri);
+ }
+ case TalerUriAction.Withdraw: {
+ return stringifyWithdrawUri(uri);
+ }
+ case TalerUriAction.Exchange: {
+ return stringifyExchangeUri(uri);
+ }
+ case TalerUriAction.Auditor: {
+ return stringifyAuditorUri(uri);
+ }
+ }
+}
+
/**
* Parse a taler[+http]://pay URI.
* Return undefined if not passed a valid URI.
@@ -222,76 +347,51 @@ export function parsePayUri(s: string): PayUriResult | undefined {
const merchantBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`);
return {
+ type: TalerUriAction.Pay,
merchantBaseUrl,
orderId,
- sessionId: sessionId,
+ sessionId,
claimToken,
noncePriv,
};
}
export function parsePayTemplateUri(
- s: string,
+ uriString: string,
): PayTemplateUriResult | undefined {
- const pi = parseProtoInfo(s, talerActionPayTemplate);
+ const pi = parseProtoInfo(uriString, talerActionPayTemplate);
if (!pi) {
return undefined;
}
- const c = pi?.rest.split("?");
- const q = new URLSearchParams(c[1] ?? "");
+ const c = pi.rest.split("?");
+
const parts = c[0].split("/");
if (parts.length < 2) {
return undefined;
}
- const host = parts[0].toLowerCase();
- const templateId = parts[parts.length - 1];
- const pathSegments = parts.slice(1, parts.length - 1);
- const p = [host, ...pathSegments].join("/");
- const merchantBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`);
+ const q = new URLSearchParams(c[1] ?? "");
const params: Record<string, string> = {};
-
q.forEach((v, k) => {
params[k] = v;
});
+ const host = parts[0].toLowerCase();
+ const templateId = parts[parts.length - 1];
+ const pathSegments = parts.slice(1, parts.length - 1);
+ const hostAndSegments = [host, ...pathSegments].join("/");
+ const merchantBaseUrl = canonicalizeBaseUrl(
+ `${pi.innerProto}://${hostAndSegments}/`,
+ );
+
return {
+ type: TalerUriAction.PayTemplate,
merchantBaseUrl,
templateId,
templateParams: params,
};
}
-export function constructPayUri(
- merchantBaseUrl: string,
- orderId: string,
- sessionId: string,
- claimToken?: string,
- noncePriv?: string,
-): string {
- const base = canonicalizeBaseUrl(merchantBaseUrl);
- const url = new URL(base);
- const isHttp = base.startsWith("http://");
- let result = isHttp ? `taler+http://pay/` : `taler://pay/`;
- result += url.hostname;
- if (url.port != "") {
- result += `:${url.port}`;
- }
- result += `${url.pathname}${orderId}/${sessionId}`;
- const qp = new URLSearchParams();
- if (claimToken) {
- qp.append("c", claimToken);
- }
- if (noncePriv) {
- qp.append("n", noncePriv);
- }
- const queryPart = qp.toString();
- if (queryPart) {
- result += "?" + queryPart;
- }
- return result;
-}
-
export function parsePayPushUri(s: string): PayPushUriResult | undefined {
const pi = parseProtoInfo(s, talerActionPayPush);
if (!pi) {
@@ -305,10 +405,13 @@ export function parsePayPushUri(s: string): PayPushUriResult | undefined {
const host = parts[0].toLowerCase();
const contractPriv = parts[parts.length - 1];
const pathSegments = parts.slice(1, parts.length - 1);
- const p = [host, ...pathSegments].join("/");
- const exchangeBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`);
+ const hostAndSegments = [host, ...pathSegments].join("/");
+ const exchangeBaseUrl = canonicalizeBaseUrl(
+ `${pi.innerProto}://${hostAndSegments}/`,
+ );
return {
+ type: TalerUriAction.PayPush,
exchangeBaseUrl,
contractPriv,
};
@@ -327,10 +430,13 @@ export function parsePayPullUri(s: string): PayPullUriResult | undefined {
const host = parts[0].toLowerCase();
const contractPriv = parts[parts.length - 1];
const pathSegments = parts.slice(1, parts.length - 1);
- const p = [host, ...pathSegments].join("/");
- const exchangeBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`);
+ const hostAndSegments = [host, ...pathSegments].join("/");
+ const exchangeBaseUrl = canonicalizeBaseUrl(
+ `${pi.innerProto}://${hostAndSegments}/`,
+ );
return {
+ type: TalerUriAction.PayPull,
exchangeBaseUrl,
contractPriv,
};
@@ -353,15 +459,67 @@ export function parseTipUri(s: string): TipUriResult | undefined {
const host = parts[0].toLowerCase();
const tipId = parts[parts.length - 1];
const pathSegments = parts.slice(1, parts.length - 1);
- const p = [host, ...pathSegments].join("/");
- const merchantBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`);
+ const hostAndSegments = [host, ...pathSegments].join("/");
+ const merchantBaseUrl = canonicalizeBaseUrl(
+ `${pi.innerProto}://${hostAndSegments}/`,
+ );
return {
+ type: TalerUriAction.Tip,
merchantBaseUrl,
merchantTipId: tipId,
};
}
+export function parseExchangeUri(s: string): ExchangeUri | undefined {
+ const pi = parseProtoInfo(s, "exchange");
+ if (!pi) {
+ return undefined;
+ }
+ const c = pi?.rest.split("?");
+ const parts = c[0].split("/");
+ if (parts.length < 2) {
+ return undefined;
+ }
+ const host = parts[0].toLowerCase();
+ const exchangePub = parts[parts.length - 1];
+ const pathSegments = parts.slice(1, parts.length - 1);
+ const hostAndSegments = [host, ...pathSegments].join("/");
+ const exchangeBaseUrl = canonicalizeBaseUrl(
+ `${pi.innerProto}://${hostAndSegments}/`,
+ );
+
+ return {
+ type: TalerUriAction.Exchange,
+ exchangeBaseUrl,
+ exchangePub,
+ };
+}
+export function parseAuditorUri(s: string): AuditorUri | undefined {
+ const pi = parseProtoInfo(s, "auditor");
+ if (!pi) {
+ return undefined;
+ }
+ const c = pi?.rest.split("?");
+ const parts = c[0].split("/");
+ if (parts.length < 2) {
+ return undefined;
+ }
+ const host = parts[0].toLowerCase();
+ const auditorPub = parts[parts.length - 1];
+ const pathSegments = parts.slice(1, parts.length - 1);
+ const hostAndSegments = [host, ...pathSegments].join("/");
+ const auditorBaseUrl = canonicalizeBaseUrl(
+ `${pi.innerProto}://${hostAndSegments}/`,
+ );
+
+ return {
+ type: TalerUriAction.Auditor,
+ auditorBaseUrl,
+ auditorPub,
+ };
+}
+
/**
* Parse a taler[+http]://refund URI.
* Return undefined if not passed a valid URI.
@@ -377,13 +535,16 @@ export function parseRefundUri(s: string): RefundUriResult | undefined {
return undefined;
}
const host = parts[0].toLowerCase();
- const sessionId = parts[parts.length - 1];
- const orderId = parts[parts.length - 2];
- const pathSegments = parts.slice(1, parts.length - 2);
- const p = [host, ...pathSegments].join("/");
- const merchantBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`);
+ // const sessionId = parts[parts.length - 1];
+ const orderId = parts[parts.length - 1];
+ const pathSegments = parts.slice(1, parts.length - 1);
+ const hostAndSegments = [host, ...pathSegments].join("/");
+ const merchantBaseUrl = canonicalizeBaseUrl(
+ `${pi.innerProto}://${hostAndSegments}/`,
+ );
return {
+ type: TalerUriAction.Refund,
merchantBaseUrl,
orderId,
};
@@ -395,89 +556,249 @@ export function parseDevExperimentUri(s: string): DevExperimentUri | undefined {
if (!c) {
return undefined;
}
- // const q = new URLSearchParams(c[1] ?? "");
const parts = c[0].split("/");
return {
+ type: TalerUriAction.DevExperiment,
devExperimentId: parts[0],
};
}
-export function constructPayPushUri(args: {
- exchangeBaseUrl: string;
- contractPriv: string;
-}): string {
- const url = new URL(args.exchangeBaseUrl);
- let proto: string;
- if (url.protocol === "https:") {
- proto = "taler";
- } else if (url.protocol === "http:") {
- proto = "taler+http";
- } else {
- throw Error(`Unsupported exchange URL protocol ${args.exchangeBaseUrl}`);
+export function parseRestoreUri(uri: string): BackupRestoreUri | undefined {
+ const pi = parseProtoInfo(uri, "restore");
+ if (!pi) {
+ return undefined;
}
- if (!url.pathname.endsWith("/")) {
- throw Error(
- `exchange base URL must end with a slash (got ${args.exchangeBaseUrl}instead)`,
- );
+ const c = pi.rest.split("?");
+ const parts = c[0].split("/");
+ if (parts.length < 2) {
+ return undefined;
}
- return `${proto}://pay-push/${url.host}${url.pathname}${args.contractPriv}`;
+
+ const walletRootPriv = parts[0];
+ if (!walletRootPriv) return undefined;
+ const providers = new Array<{ name: string; url: string }>();
+ parts[1].split(",").map((name) => {
+ const url = canonicalizeBaseUrl(`${pi.innerProto}://${name}/`);
+ providers.push({ name, url });
+ });
+ return {
+ type: TalerUriAction.Restore,
+ walletRootPriv,
+ providers,
+ };
+}
+
+// ================================================
+// To string functions
+// ================================================
+
+/**
+ * @deprecated use stringifyRecoveryUri
+ */
+export function constructRecoveryUri(args: {
+ walletRootPriv: string;
+ providers: {
+ name: string;
+ url: string;
+ }[];
+}): string {
+ return stringifyRestoreUri(args);
}
+/**
+ * @deprecated stringifyPayPullUri
+ */
export function constructPayPullUri(args: {
exchangeBaseUrl: string;
contractPriv: string;
}): string {
- const url = new URL(args.exchangeBaseUrl);
+ return stringifyPayPullUri(args);
+}
+
+/**
+ * @deprecated use stringifyPayPushUri
+ */
+export function constructPayPushUri(args: {
+ exchangeBaseUrl: string;
+ contractPriv: string;
+}): string {
+ return stringifyPayPushUri(args);
+}
+
+/**
+ *
+ * @deprecated use stringifyPayUri
+ */
+export function constructPayUri(
+ merchantBaseUrl: string,
+ orderId: string,
+ sessionId: string,
+ claimToken?: string,
+ noncePriv?: string,
+): string {
+ return stringifyPayUri({
+ merchantBaseUrl,
+ orderId,
+ sessionId,
+ claimToken,
+ noncePriv,
+ });
+}
+
+export function stringifyPayUri({
+ merchantBaseUrl,
+ orderId,
+ sessionId,
+ claimToken,
+ noncePriv,
+}: Omit<PayUriResult, "type">): string {
+ // const base = canonicalizeBaseUrl(merchantBaseUrl);
+ // const url = new URL(base);
+ // const isHttp = base.startsWith("http://");
+ // let result = isHttp ? `taler+http://pay/` : `taler://pay/`;
+ // result += url.hostname;
+ // if (url.port != "") {
+ // result += `:${url.port}`;
+ // }
+ // result += `${url.pathname}${orderId}/${sessionId}`;
+ // const qp = new URLSearchParams();
+ // if (claimToken) {
+ // qp.append("c", claimToken);
+ // }
+ // if (noncePriv) {
+ // qp.append("n", noncePriv);
+ // }
+ // const queryPart = qp.toString();
+ // if (queryPart) {
+ // result += "?" + queryPart;
+ // }
+ const { proto, path, query } = getUrlInfo(merchantBaseUrl, {
+ c: claimToken,
+ n: noncePriv,
+ });
+ return `${proto}://pay/${path}${orderId}/${sessionId}${query}`;
+}
+
+export function stringifyPayPullUri({
+ contractPriv,
+ exchangeBaseUrl,
+}: Omit<PayPullUriResult, "type">): string {
+ const { proto, path } = getUrlInfo(exchangeBaseUrl);
+ return `${proto}://pay-pull/${path}${contractPriv}`;
+}
+
+export function stringifyPayPushUri({
+ contractPriv,
+ exchangeBaseUrl,
+}: Omit<PayPushUriResult, "type">): string {
+ const { proto, path } = getUrlInfo(exchangeBaseUrl);
+
+ return `${proto}://pay-push/${path}${contractPriv}`;
+}
+
+export function stringifyRestoreUri({
+ providers,
+ walletRootPriv,
+}: Omit<BackupRestoreUri, "type">): string {
+ const list = providers.map((p) => `${new URL(p.url).hostname}`).join("m");
+ return `taler://restore/${walletRootPriv}/${list}`;
+}
+
+export function stringifyDevExperimentUri({
+ devExperimentId,
+}: Omit<DevExperimentUri, "type">): string {
+ return `taler://dev-experiment/${devExperimentId}`;
+}
+
+export function stringifyPayTemplateUri({
+ merchantBaseUrl,
+ templateId,
+ templateParams,
+}: Omit<PayTemplateUriResult, "type">): string {
+ const { proto, path, query } = getUrlInfo(merchantBaseUrl, templateParams);
+ return `${proto}://pay-template/${path}${templateId}${query}`;
+}
+export function stringifyRefundUri({
+ merchantBaseUrl,
+ orderId,
+}: Omit<RefundUriResult, "type">): string {
+ const { proto, path } = getUrlInfo(merchantBaseUrl);
+ return `${proto}://refund/${path}${orderId}`;
+}
+export function stringifyTipUri({
+ merchantBaseUrl,
+ merchantTipId,
+}: Omit<TipUriResult, "type">): string {
+ const { proto, path } = getUrlInfo(merchantBaseUrl);
+ return `${proto}://tip/${path}${merchantTipId}`;
+}
+
+export function stringifyExchangeUri({
+ exchangeBaseUrl,
+ exchangePub,
+}: Omit<ExchangeUri, "type">): string {
+ const { proto, path } = getUrlInfo(exchangeBaseUrl);
+ return `${proto}://exchange/${path}${exchangePub}`;
+}
+
+export function stringifyAuditorUri({
+ auditorBaseUrl,
+ auditorPub,
+}: Omit<AuditorUri, "type">): string {
+ const { proto, path } = getUrlInfo(auditorBaseUrl);
+ return `${proto}://auditor/${path}${auditorPub}`;
+}
+
+export function stringifyWithdrawUri({
+ bankIntegrationApiBaseUrl,
+ withdrawalOperationId,
+}: Omit<WithdrawUriResult, "type">): string {
+ const { proto, path } = getUrlInfo(bankIntegrationApiBaseUrl);
+ return `${proto}://withdraw/${path}${withdrawalOperationId}`;
+}
+
+/**
+ * Use baseUrl to defined http or https
+ * create path using host+port+pathname
+ * use params to create a query parameter string or empty
+ *
+ * @param baseUrl
+ * @param params
+ * @returns
+ */
+function getUrlInfo(
+ baseUrl: string,
+ params: Record<string, string | undefined> = {},
+): { proto: string; path: string; query: string } {
+ const url = new URL(baseUrl);
let proto: string;
if (url.protocol === "https:") {
proto = "taler";
} else if (url.protocol === "http:") {
proto = "taler+http";
} else {
- throw Error(`Unsupported exchange URL protocol ${args.exchangeBaseUrl}`);
+ throw Error(`Unsupported URL protocol in ${baseUrl}`);
}
- if (!url.pathname.endsWith("/")) {
- throw Error(
- `exchange base URL must end with a slash (got ${args.exchangeBaseUrl}instead)`,
- );
+ let path = url.hostname;
+ if (url.port) {
+ path = path + ":" + url.port;
}
- return `${proto}://pay-pull/${url.host}${url.pathname}${args.contractPriv}`;
-}
-
-export function constructRecoveryUri(args: BackupRecovery): string {
- const key = args.walletRootPriv;
- //FIXME: name may contain non valid characters
- const urls = args.providers
- .map((p) => `${p.name}=${canonicalizeBaseUrl(p.url)}`)
- .join("&");
-
- return `taler://recovery/${key}?${urls}`;
-}
-export function parseRecoveryUri(uri: string): BackupRecovery | undefined {
- const pi = parseProtoInfo(uri, "recovery");
- if (!pi) {
- return undefined;
+ if (url.pathname) {
+ path = path + url.pathname;
}
- const idx = pi.rest.indexOf("?");
- if (idx === -1) {
- return undefined;
+ if (!path.endsWith("/")) {
+ path = path + "/";
}
- const path = pi.rest.slice(0, idx);
- const params = pi.rest.slice(idx + 1);
- if (!path || !params) {
- return undefined;
- }
- const parts = path.split("/");
- const walletRootPriv = parts[0];
- if (!walletRootPriv) return undefined;
- const providers = new Array<{ name: string; url: string }>();
- const args = params.split("&");
- for (const param in args) {
- const eq = args[param].indexOf("=");
- if (eq === -1) return undefined;
- const name = args[param].slice(0, eq);
- const url = args[param].slice(eq + 1);
- providers.push({ name, url });
- }
- return { walletRootPriv, providers };
+
+ const qp = new URLSearchParams();
+ let withParams = false;
+ Object.entries(params).forEach(([name, value]) => {
+ if (value) {
+ withParams = true;
+ qp.append(name, value);
+ }
+ });
+ const query = withParams ? "?" + qp.toString() : "";
+
+ return { proto, path, query };
}