Merge pull request #12 from Start9Labs/feat/getConfigWithMatcher

feat: Add Matcher/ Parser for spec in getConfig
This commit is contained in:
J M
2023-01-09 11:39:11 -07:00
committed by GitHub
6 changed files with 1250 additions and 169 deletions

View File

@@ -2,6 +2,7 @@ import { YAML } from "../dependencies.ts";
import { matches } from "../dependencies.ts";
import { ExpectedExports } from "../types.ts";
import { ConfigSpec } from "../types.ts";
import { typeFromProps, TypeFromProps } from "../utils/propertiesMatcher.ts";
const { any, string, dictionary } = matches;
@@ -15,7 +16,8 @@ const matchConfig = dictionary([string, any]);
* @param spec
* @returns
*/
export const getConfig = (spec: ConfigSpec): ExpectedExports.getConfig =>
export const getConfig =
(spec: ConfigSpec): ExpectedExports.getConfig =>
async (effects) => {
const config = await effects
.readFile({
@@ -36,3 +38,37 @@ export const getConfig = (spec: ConfigSpec): ExpectedExports.getConfig =>
},
};
};
/**
* Call with the configuration to get a standard getConfig for the expected exports
* Assumption: start9/config.yaml is where the config will be stored
* Throws: Error if there is no file
* Throws: Error if the config.yaml isn't yaml nor config shape
* @param spec
* @returns A funnction for getConfig and the matcher for the spec sent in
*/
export const getConfigAndMatcher = <Spec extends ConfigSpec>(
spec: Spec
): [ExpectedExports.getConfig, matches.Parser<unknown, TypeFromProps<Spec>>] => [
async (effects) => {
const config = await effects
.readFile({
path: "start9/config.yaml",
volumeId: "main",
})
.then((x) => YAML.parse(x))
.then((x) => matchConfig.unsafeCast(x))
.catch((e) => {
effects.info(`Got error ${e} while trying to read the config`);
return undefined;
});
return {
result: {
config,
spec,
},
};
},
typeFromProps(spec),
];

View File

@@ -1 +1,2 @@
import "./emver-lite/test.ts";
import "./utils/propertiesMatcher.test.ts";

256
types.ts
View File

@@ -1,36 +1,21 @@
// deno-lint-ignore no-namespace
export namespace ExpectedExports {
/** Set configuration is called after we have modified and saved the configuration in the embassy ui. Use this to make a file for the docker to read from for configuration. */
export type setConfig = (
effects: Effects,
input: Config,
) => Promise<ResultType<SetResult>>;
export type setConfig = (effects: Effects, input: Config) => Promise<ResultType<SetResult>>;
/** Get configuration returns a shape that describes the format that the embassy ui will generate, and later send to the set config */
export type getConfig = (effects: Effects) => Promise<ResultType<ConfigRes>>;
/** These are how we make sure the our dependency configurations are valid and if not how to fix them. */
export type dependencies = Dependencies;
/** Properties are used to get values from the docker, like a username + password, what ports we are hosting from */
export type properties = (
effects: Effects,
) => Promise<ResultType<Properties>>;
export type properties = (effects: Effects) => Promise<ResultType<Properties>>;
export type health = {
/** Should be the health check id */
[id: string]: (
effects: Effects,
dateMs: number,
) => Promise<ResultType<unknown>>;
[id: string]: (effects: Effects, dateMs: number) => Promise<ResultType<unknown>>;
};
export type migration = (
effects: Effects,
version: string,
...args: unknown[]
) => Promise<ResultType<MigrationRes>>;
export type migration = (effects: Effects, version: string, ...args: unknown[]) => Promise<ResultType<MigrationRes>>;
export type action = {
[id: string]: (
effects: Effects,
config?: Config,
) => Promise<ResultType<ActionResult>>;
[id: string]: (effects: Effects, config?: Config) => Promise<ResultType<ActionResult>>;
};
/**
@@ -43,9 +28,7 @@ export namespace ExpectedExports {
/** Used to reach out from the pure js runtime */
export type Effects = {
/** Usable when not sandboxed */
writeFile(
input: { path: string; volumeId: string; toWrite: string },
): Promise<void>;
writeFile(input: { path: string; volumeId: string; toWrite: string }): Promise<void>;
readFile(input: { volumeId: string; path: string }): Promise<string>;
metadata(input: { volumeId: string; path: string }): Promise<Metadata>;
/** Create a directory. Usable when not sandboxed */
@@ -55,35 +38,18 @@ export type Effects = {
removeFile(input: { volumeId: string; path: string }): Promise<void>;
/** Write a json file into an object. Usable when not sandboxed */
writeJsonFile(
input: { volumeId: string; path: string; toWrite: Record<string, unknown> },
): Promise<void>;
writeJsonFile(input: { volumeId: string; path: string; toWrite: Record<string, unknown> }): Promise<void>;
/** Read a json file into an object */
readJsonFile(
input: { volumeId: string; path: string },
): Promise<Record<string, unknown>>;
readJsonFile(input: { volumeId: string; path: string }): Promise<Record<string, unknown>>;
runCommand(
input: {
command: string;
args?: string[];
timeoutMillis?: number;
},
): Promise<ResultType<string>>;
runDaemon(
input: {
command: string;
args?: string[];
},
): {
runCommand(input: { command: string; args?: string[]; timeoutMillis?: number }): Promise<ResultType<string>>;
runDaemon(input: { command: string; args?: string[] }): {
wait(): Promise<ResultType<string>>;
term(): Promise<void>;
};
sleep(
timeMs: number,
): Promise<null>;
sleep(timeMs: number): Promise<null>;
/** Log at the trace level */
trace(whatToPrint: string): void;
@@ -101,17 +67,14 @@ export type Effects = {
exists(input: { volumeId: string; path: string }): Promise<boolean>;
fetch(url: string, options?: {
method?:
| "GET"
| "POST"
| "PUT"
| "DELETE"
| "HEAD"
| "PATCH";
headers?: Record<string, string>;
body?: string;
}): Promise<{
fetch(
url: string,
options?: {
method?: "GET" | "POST" | "PUT" | "DELETE" | "HEAD" | "PATCH";
headers?: Record<string, string>;
body?: string;
}
): Promise<{
method: string;
ok: boolean;
status: number;
@@ -196,8 +159,8 @@ export type Target<T extends string, V> = V & {
export type UniqueBy =
| {
any: UniqueBy[];
}
any: UniqueBy[];
}
| string
| null;
@@ -207,22 +170,20 @@ export type WithNullable<T> = T & {
export type DefaultString =
| string
| {
/** The chars available for the randome generation */
charset?: string;
/** Length that we generate to */
len: number;
};
/** The chars available for the randome generation */
charset?: string;
/** Length that we generate to */
len: number;
};
export type ValueSpecString =
& (
// deno-lint-ignore ban-types
export type ValueSpecString = // deno-lint-ignore ban-types
(
| {}
| {
pattern: string;
"pattern-description": string;
}
)
& {
pattern: string;
"pattern-description": string;
}
) & {
copyable?: boolean;
masked?: boolean;
placeholder?: string;
@@ -238,71 +199,63 @@ export type ValueSpecNumber = {
export type ValueSpecBoolean = Record<string, unknown>;
export type ValueSpecAny =
| Tag<"boolean", WithDescription<WithDefault<ValueSpecBoolean, boolean>>>
| Tag<"string", WithDescription<WithNullableDefault<WithNullable<ValueSpecString>, DefaultString>>>
| Tag<"number", WithDescription<WithNullableDefault<WithNullable<ValueSpecNumber>, number>>>
| Tag<
"string",
WithDescription<
WithNullableDefault<WithNullable<ValueSpecString>, DefaultString>
>
>
| Tag<
"number",
WithDescription<WithNullableDefault<WithNullable<ValueSpecNumber>, number>>
>
| Tag<
"enum",
WithDescription<
WithDefault<
{
values: string[];
"value-names": {
[key: string]: string;
};
},
string
"enum",
WithDescription<
WithDefault<
{
values: readonly string[] | string[];
"value-names": {
[key: string]: string;
};
},
string
>
>
>
>
| Tag<"list", ValueSpecList>
| Tag<"object", WithDescription<WithNullableDefault<ValueSpecObject, Config>>>
| Tag<"union", WithDescription<WithDefault<ValueSpecUnion, string>>>
| Tag<
"pointer",
WithDescription<
| Subtype<
"package",
| Target<
"tor-key",
{
"package-id": string;
interface: string;
}
>
| Target<
"tor-address",
{
"package-id": string;
interface: string;
}
>
| Target<
"lan-address",
{
"package-id": string;
interface: string;
}
>
| Target<
"config",
{
"package-id": string;
selector: string;
multi: boolean;
}
>
"pointer",
WithDescription<
| Subtype<
"package",
| Target<
"tor-key",
{
"package-id": string;
interface: string;
}
>
| Target<
"tor-address",
{
"package-id": string;
interface: string;
}
>
| Target<
"lan-address",
{
"package-id": string;
interface: string;
}
>
| Target<
"config",
{
"package-id": string;
selector: string;
multi: boolean;
}
>
>
| Subtype<"system", Record<string, unknown>>
>
| Subtype<"system", Record<string, unknown>>
>
>;
>;
export type ValueSpecUnion = {
/** What tag for the specification, for tag unions */
tag: {
@@ -326,39 +279,12 @@ export type ValueSpecObject = {
"unique-by"?: UniqueBy;
};
export type ValueSpecList =
| Subtype<
"boolean",
WithDescription<WithDefault<ListSpec<ValueSpecBoolean>, boolean[]>>
>
| Subtype<
"string",
WithDescription<WithDefault<ListSpec<ValueSpecString>, string[]>>
>
| Subtype<
"number",
WithDescription<WithDefault<ListSpec<ValueSpecNumber>, number[]>>
>
| Subtype<
"enum",
WithDescription<
WithDefault<
ListSpec<
ValueSpecEnum
>,
string[]
>
>
>
| Subtype<
"object",
WithDescription<
WithDefault<ListSpec<ValueSpecObject>, Record<string, unknown>[]>
>
>
| Subtype<
"union",
WithDescription<WithDefault<ListSpec<ValueSpecUnion>, string[]>>
>;
| Subtype<"boolean", WithDescription<WithDefault<ListSpec<ValueSpecBoolean>, boolean[]>>>
| Subtype<"string", WithDescription<WithDefault<ListSpec<ValueSpecString>, string[]>>>
| Subtype<"number", WithDescription<WithDefault<ListSpec<ValueSpecNumber>, number[]>>>
| Subtype<"enum", WithDescription<WithDefault<ListSpec<ValueSpecEnum>, string[]>>>
| Subtype<"object", WithDescription<WithNullableDefault<ListSpec<ValueSpecObject>, Record<string, unknown>[]>>>
| Subtype<"union", WithDescription<WithDefault<ListSpec<ValueSpecUnion>, string[]>>>;
export type ValueSpecEnum = {
values: string[];
"value-names": { [key: string]: string };
@@ -407,9 +333,11 @@ export type DependsOn = {
[packageId: string]: string[];
};
export type KnownError = { error: string } | {
"error-code": [number, string] | readonly [number, string];
};
export type KnownError =
| { error: string }
| {
"error-code": [number, string] | readonly [number, string];
};
export type ResultType<T> = KnownError | { result: T };
export type PackagePropertiesV2 = {

11
util.ts
View File

@@ -1,5 +1,7 @@
import * as T from "./types.ts";
export { guardAll, typeFromProps } from "./utils/propertiesMatcher.ts";
export function unwrapResultType<T>(res: T.ResultType<T>): T {
if ("error-code" in res) {
throw new Error(res["error-code"][1]);
@@ -11,10 +13,11 @@ export function unwrapResultType<T>(res: T.ResultType<T>): T {
}
/** Used to check if the file exists before hand */
export const exists = (
effects: T.Effects,
props: { path: string; volumeId: string },
) => effects.metadata(props).then((_) => true, (_) => false);
export const exists = (effects: T.Effects, props: { path: string; volumeId: string }) =>
effects.metadata(props).then(
(_) => true,
(_) => false
);
export const errorCode = (code: number, error: string) => ({
"error-code": [code, error] as const,

View File

@@ -0,0 +1,842 @@
import * as PM from "./propertiesMatcher.ts";
import { expect } from "https://deno.land/x/expect@v0.2.9/mod.ts";
import { matches } from "../dependencies.ts";
const randWithSeed = (seed = 1) => {
return function random() {
const x = Math.sin(seed++) * 10000;
return x - Math.floor(x);
};
};
const bitcoinProperties = {
"peer-tor-address": {
name: "Peer Tor Address",
description: "The Tor address of the peer interface",
type: "pointer",
subtype: "package",
"package-id": "bitcoind",
target: "tor-address",
interface: "peer",
},
"rpc-tor-address": {
name: "RPC Tor Address",
description: "The Tor address of the RPC interface",
type: "pointer",
subtype: "package",
"package-id": "bitcoind",
target: "tor-address",
interface: "rpc",
},
rpc: {
type: "object",
name: "RPC Settings",
description: "RPC configuration options.",
spec: {
enable: {
type: "boolean",
name: "Enable",
description: "Allow remote RPC requests.",
default: true,
},
username: {
type: "string",
nullable: false,
name: "Username",
description: "The username for connecting to Bitcoin over RPC.",
default: "bitcoin",
masked: true,
pattern: "^[a-zA-Z0-9_]+$",
"pattern-description": "Must be alphanumeric (can contain underscore).",
},
password: {
type: "string",
nullable: false,
name: "RPC Password",
description: "The password for connecting to Bitcoin over RPC.",
default: {
charset: "a-z,2-7",
len: 20,
},
pattern: '^[^\\n"]*$',
"pattern-description": "Must not contain newline or quote characters.",
copyable: true,
masked: true,
},
advanced: {
type: "object",
name: "Advanced",
description: "Advanced RPC Settings",
spec: {
auth: {
name: "Authorization",
description:
"Username and hashed password for JSON-RPC connections. RPC clients connect using the usual http basic authentication.",
type: "list",
subtype: "string",
default: Array<string>(),
spec: {
pattern: "^[a-zA-Z0-9_-]+:([0-9a-fA-F]{2})+\\$([0-9a-fA-F]{2})+$",
"pattern-description": 'Each item must be of the form "<USERNAME>:<SALT>$<HASH>".',
},
range: "[0,*)",
},
serialversion: {
name: "Serialization Version",
description: "Return raw transaction or block hex with Segwit or non-SegWit serialization.",
type: "enum",
values: ["non-segwit", "segwit"],
"value-names": {},
default: "segwit",
},
servertimeout: {
name: "Rpc Server Timeout",
description: "Number of seconds after which an uncompleted RPC call will time out.",
type: "number",
nullable: false,
range: "[5,300]",
integral: true,
units: "seconds",
default: 30,
},
threads: {
name: "Threads",
description:
"Set the number of threads for handling RPC calls. You may wish to increase this if you are making lots of calls via an integration.",
type: "number",
nullable: false,
default: 16,
range: "[1,64]",
integral: true,
units: undefined,
},
workqueue: {
name: "Work Queue",
description:
"Set the depth of the work queue to service RPC calls. Determines how long the backlog of RPC requests can get before it just rejects new ones.",
type: "number",
nullable: false,
default: 128,
range: "[8,256]",
integral: true,
units: "requests",
},
},
},
},
},
"zmq-enabled": {
type: "boolean",
name: "ZeroMQ Enabled",
description: "Enable the ZeroMQ interface",
default: true,
},
txindex: {
type: "boolean",
name: "Transaction Index",
description: "Enable the Transaction Index (txindex)",
default: true,
},
wallet: {
type: "object",
name: "Wallet",
description: "Wallet Settings",
spec: {
enable: {
name: "Enable Wallet",
description: "Load the wallet and enable wallet RPC calls.",
type: "boolean",
default: true,
},
avoidpartialspends: {
name: "Avoid Partial Spends",
description:
"Group outputs by address, selecting all or none, instead of selecting on a per-output basis. This improves privacy at the expense of higher transaction fees.",
type: "boolean",
default: true,
},
discardfee: {
name: "Discard Change Tolerance",
description:
"The fee rate (in BTC/kB) that indicates your tolerance for discarding change by adding it to the fee.",
type: "number",
nullable: false,
default: 0.0001,
range: "[0,.01]",
integral: false,
units: "BTC/kB",
},
},
},
advanced: {
type: "object",
name: "Advanced",
description: "Advanced Settings",
spec: {
mempool: {
type: "object",
name: "Mempool",
description: "Mempool Settings",
spec: {
mempoolfullrbf: {
name: "Enable Full RBF",
description:
"Policy for your node to use for relaying and mining unconfirmed transactions. For details, see https://github.com/bitcoin/bitcoin/blob/master/doc/release-notes/release-notes-24.0.md#notice-of-new-option-for-transaction-replacement-policies",
type: "boolean",
default: false,
},
persistmempool: {
type: "boolean",
name: "Persist Mempool",
description: "Save the mempool on shutdown and load on restart.",
default: true,
},
maxmempool: {
type: "number",
nullable: false,
name: "Max Mempool Size",
description: "Keep the transaction memory pool below <n> megabytes.",
range: "[1,*)",
integral: true,
units: "MiB",
default: 300,
},
mempoolexpiry: {
type: "number",
nullable: false,
name: "Mempool Expiration",
description: "Do not keep transactions in the mempool longer than <n> hours.",
range: "[1,*)",
integral: true,
units: "Hr",
default: 336,
},
},
},
peers: {
type: "object",
name: "Peers",
description: "Peer Connection Settings",
spec: {
listen: {
type: "boolean",
name: "Make Public",
description: "Allow other nodes to find your server on the network.",
default: true,
},
onlyconnect: {
type: "boolean",
name: "Disable Peer Discovery",
description: "Only connect to specified peers.",
default: false,
},
onlyonion: {
type: "boolean",
name: "Disable Clearnet",
description: "Only connect to peers over Tor.",
default: false,
},
addnode: {
name: "Add Nodes",
description: "Add addresses of nodes to connect to.",
type: "list",
subtype: "object",
range: "[0,*)",
default: Array<Record<string, unknown>>(),
spec: {
spec: {
hostname: {
type: "string",
nullable: false,
name: "Hostname",
description: "Domain or IP address of bitcoin peer",
pattern:
"(^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$)|((^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$)|(^[a-z2-7]{16}\\.onion$)|(^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$))",
"pattern-description":
"Must be either a domain name, or an IPv4 or IPv6 address. Do not include protocol scheme (eg 'http://') or port.",
},
port: {
type: "number",
nullable: true,
name: "Port",
description: "Port that peer is listening on for inbound p2p connections",
range: "[0,65535]",
integral: true,
},
},
},
},
},
},
dbcache: {
type: "number",
nullable: true,
name: "Database Cache",
description:
"How much RAM to allocate for caching the TXO set. Higher values improve syncing performance, but increase your chance of using up all your system's memory or corrupting your database in the event of an ungraceful shutdown. Set this high but comfortably below your system's total RAM during IBD, then turn down to 450 (or leave blank) once the sync completes.",
warning:
"WARNING: Increasing this value results in a higher chance of ungraceful shutdowns, which can leave your node unusable if it happens during the initial block download. Use this setting with caution. Be sure to set this back to the default (450 or leave blank) once your node is synced. DO NOT press the STOP button if your dbcache is large. Instead, set this number back to the default, hit save, and wait for bitcoind to restart on its own.",
range: "(0,*)",
integral: true,
units: "MiB",
},
pruning: {
type: "union",
name: "Pruning Settings",
description: "Blockchain Pruning Options\nReduce the blockchain size on disk\n",
warning:
"If you set pruning to Manual and your disk is smaller than the total size of the blockchain, you MUST have something running that prunes these blocks or you may overfill your disk!\nDisabling pruning will convert your node into a full archival node. This requires a resync of the entire blockchain, a process that may take several days. Make sure you have enough free disk space or you may fill up your disk.\n",
tag: {
id: "mode",
name: "Pruning Mode",
description:
'- Disabled: Disable pruning\n- Automatic: Limit blockchain size on disk to a certain number of megabytes\n- Manual: Prune blockchain with the "pruneblockchain" RPC\n',
"variant-names": {
disabled: "Disabled",
automatic: "Automatic",
manual: "Manual",
},
},
variants: {
disabled: {},
automatic: {
size: {
type: "number",
nullable: false,
name: "Max Chain Size",
description: "Limit of blockchain size on disk.",
warning: "Increasing this value will require re-syncing your node.",
default: 550,
range: "[550,1000000)",
integral: true,
units: "MiB",
},
},
manual: {
size: {
type: "number",
nullable: false,
name: "Failsafe Chain Size",
description: "Prune blockchain if size expands beyond this.",
default: 65536,
range: "[550,1000000)",
integral: true,
units: "MiB",
},
},
},
default: "disabled",
},
blockfilters: {
type: "object",
name: "Block Filters",
description: "Settings for storing and serving compact block filters",
spec: {
blockfilterindex: {
type: "boolean",
name: "Compute Compact Block Filters (BIP158)",
description:
"Generate Compact Block Filters during initial sync (IBD) to enable 'getblockfilter' RPC. This is useful if dependent services need block filters to efficiently scan for addresses/transactions etc.",
default: true,
},
peerblockfilters: {
type: "boolean",
name: "Serve Compact Block Filters to Peers (BIP157)",
description:
"Serve Compact Block Filters as a peer service to other nodes on the network. This is useful if you wish to connect an SPV client to your node to make it efficient to scan transactions without having to download all block data. 'Compute Compact Block Filters (BIP158)' is required.",
default: false,
},
},
},
bloomfilters: {
type: "object",
name: "Bloom Filters (BIP37)",
description: "Setting for serving Bloom Filters",
spec: {
peerbloomfilters: {
type: "boolean",
name: "Serve Bloom Filters to Peers",
description:
"Peers have the option of setting filters on each connection they make after the version handshake has completed. Bloom filters are for clients implementing SPV (Simplified Payment Verification) that want to check that block headers connect together correctly, without needing to verify the full blockchain. The client must trust that the transactions in the chain are in fact valid. It is highly recommended AGAINST using for anything except Bisq integration.",
warning: "This is ONLY for use with Bisq integration, please use Block Filters for all other applications.",
default: false,
},
},
},
},
matches,
},
} as const;
type BitcoinProperties = typeof bitcoinProperties;
const anyValue: unknown = "";
const _testBoolean: boolean = anyValue as PM.GuardAll<BitcoinProperties["rpc"]["spec"]["enable"]>;
// @ts-expect-error Boolean can't be a string
const _testBooleanBad: string = anyValue as PM.GuardAll<BitcoinProperties["rpc"]["spec"]["enable"]>;
const _testString: string = anyValue as PM.GuardAll<BitcoinProperties["rpc"]["spec"]["username"]>;
// @ts-expect-error string can't be a boolean
const _testStringBad: boolean = anyValue as PM.GuardAll<BitcoinProperties["rpc"]["spec"]["username"]>;
const _testNumber: number = anyValue as PM.GuardAll<BitcoinProperties["advanced"]["spec"]["dbcache"]>;
// @ts-expect-error Number can't be string
const _testNumberBad: string = anyValue as PM.GuardAll<BitcoinProperties["advanced"]["spec"]["dbcache"]>;
const _testObject: {
enable: boolean;
avoidpartialspends: boolean;
discardfee: number;
} = anyValue as PM.GuardAll<BitcoinProperties["wallet"]>;
// @ts-expect-error Boolean can't be object
const _testObjectBad: boolean = anyValue as PM.GuardAll<BitcoinProperties["wallet"]>;
const _testObjectNested: { test: { a: boolean } } = anyValue as PM.GuardAll<{
readonly type: "object";
readonly spec: {
readonly test: {
readonly type: "object";
readonly spec: {
readonly a: {
readonly type: "boolean";
};
};
};
};
}>;
const _testList: readonly string[] = anyValue as PM.GuardAll<{
type: "list";
subtype: "string";
default: [];
}>;
// @ts-expect-error number[] can't be string[]
const _testListBad: readonly number[] = anyValue as PM.GuardAll<{
type: "list";
subtype: "string";
default: [];
}>;
const _testPointer: { _UNKNOWN: "Pointer" } = anyValue as PM.GuardAll<{
type: "pointer";
}>;
const testUnionValue = anyValue as PM.GuardAll<{
type: "union";
tag: {
id: "mode";
name: "Pruning Mode";
description: '- Disabled: Disable pruning\n- Automatic: Limit blockchain size on disk to a certain number of megabytes\n- Manual: Prune blockchain with the "pruneblockchain" RPC\n';
"variant-names": {
disabled: "Disabled";
automatic: "Automatic";
manual: "Manual";
};
};
variants: {
disabled: Record<string, never>;
automatic: {
size: {
type: "number";
nullable: false;
name: "Max Chain Size";
description: "Limit of blockchain size on disk.";
warning: "Increasing this value will require re-syncing your node.";
default: 550;
range: "[550,1000000)";
integral: true;
units: "MiB";
};
};
manual: {
size: {
type: "number";
nullable: false;
name: "Failsafe Chain Size";
description: "Prune blockchain if size expands beyond this.";
default: 65536;
range: "[550,1000000)";
integral: true;
units: "MiB";
};
};
};
default: "disabled";
}>;
const _testUnion:
| { mode: "disabled" }
| { mode: "automatic"; size: number }
| {
mode: "manual";
size: number;
} = testUnionValue;
//@ts-expect-error Bad mode name
const _testUnionBadUnion:
| { mode: "disabled" }
| { mode: "bad"; size: number }
| {
mode: "manual";
size: number;
} = testUnionValue;
const _testAll: PM.TypeFromProps<BitcoinProperties> = anyValue as {
// deno-lint-ignore no-explicit-any
"peer-tor-address": any;
// deno-lint-ignore no-explicit-any
"rpc-tor-address": any;
rpc: {
enable: boolean;
username: string;
password: string;
advanced: {
auth: string[];
serialversion: "non-segwit" | "segwit";
servertimeout: number;
threads: number;
workqueue: number;
};
};
"zmq-enabled": boolean;
txindex: boolean;
wallet: {
enable: boolean;
avoidpartialspends: boolean;
discardfee: number;
};
advanced: {
mempool: {
mempoolfullrbf: boolean;
persistmempool: boolean;
maxmempool: number;
mempoolexpiry: number;
};
peers: {
listen: boolean;
onlyconnect: boolean;
onlyonion: boolean;
addnode: readonly { hostname: string; port: number }[];
};
dbcache: number;
pruning:
| { mode: "disabled" }
| { mode: "automatic"; size: number }
| {
mode: "manual";
size: number;
};
blockfilters: {
blockfilterindex: boolean;
peerblockfilters: boolean;
};
bloomfilters: {
peerbloomfilters: boolean;
};
};
};
const { test } = Deno;
{
test("matchNumberWithRange (1,4)", () => {
const checker = PM.matchNumberWithRange("(1,4)");
expect(checker.test(0)).toBe(false);
expect(checker.test(1)).toBe(false);
expect(checker.test(2)).toBe(true);
expect(checker.test(3)).toBe(true);
expect(checker.test(4)).toBe(false);
expect(checker.test(5)).toBe(false);
});
test("matchNumberWithRange [1,4]", () => {
const checker = PM.matchNumberWithRange("[1,4]");
expect(checker.test(0)).toBe(false);
expect(checker.test(1)).toBe(true);
expect(checker.test(2)).toBe(true);
expect(checker.test(3)).toBe(true);
expect(checker.test(4)).toBe(true);
expect(checker.test(5)).toBe(false);
});
test("matchNumberWithRange [1,*)", () => {
const checker = PM.matchNumberWithRange("[1,*)");
expect(checker.test(0)).toBe(false);
expect(checker.test(1)).toBe(true);
expect(checker.test(2)).toBe(true);
expect(checker.test(3)).toBe(true);
expect(checker.test(4)).toBe(true);
expect(checker.test(5)).toBe(true);
});
test("matchNumberWithRange (*,4]", () => {
const checker = PM.matchNumberWithRange("(*,4]");
expect(checker.test(0)).toBe(true);
expect(checker.test(1)).toBe(true);
expect(checker.test(2)).toBe(true);
expect(checker.test(3)).toBe(true);
expect(checker.test(4)).toBe(true);
expect(checker.test(5)).toBe(false);
});
}
{
test("Generate 1", () => {
const random = randWithSeed(1);
const options = { random };
const generated = PM.generateDefault({ charset: "a-z,B-X,2-5", len: 100 }, options);
expect(generated.length).toBe(100);
expect(generated).toBe(
"WwwgjGRkvDaGQSLeKTtlOmdDbXoCBkOn3dxUvkKkrlOFd4FbKuvIosvfPTQhbWCTQakqnwpoHmPnbgyK5CGtSQyGhxEGLjS3oKko"
);
});
test("Generate Tests", () => {
const random = randWithSeed(2);
const options = { random };
expect(PM.generateDefault({ charset: "0-1", len: 100 }, options)).toBe(
"0000110010000000000011110000010010000011101111001000000000000000100001101000010000001000010000010110"
);
expect(PM.generateDefault({ charset: "a-z", len: 100 }, options)).toBe(
"qipnycbqmqdtflrhnckgrhftrqnvxbhyyfehpvficljseasxwdyleacmjqemmpnuotkwzlsqdumuaaksxykchljgdoslrfubhepr"
);
expect(PM.generateDefault({ charset: "a,b,c,d,f,g", len: 100 }, options)).toBe(
"bagbafcgaaddcabdfadccaadfbddffdcfccfbafbddbbfcdggfcgaffdbcgcagcfbdbfaagbfgfccdbfdfbdagcfdcabbdffaffc"
);
});
}
{
test("Specs Union", () => {
const checker = PM.guardAll(bitcoinProperties.advanced.spec.pruning);
console.log("Checker = ", matches.Parser.parserAsString(checker.parser));
checker.unsafeCast({ mode: "automatic", size: 1234 });
});
test("Full spec", () => {
const checker = PM.typeFromProps(bitcoinProperties);
checker.unsafeCast({
"peer-tor-address": "",
"rpc-tor-address": "",
rpc: {
enable: true,
username: "asdf",
password: "asdf",
advanced: {
auth: ["test:34$aa"],
serialversion: "non-segwit",
servertimeout: 12,
threads: 12,
workqueue: 12,
},
},
"zmq-enabled": false,
txindex: false,
wallet: {
enable: true,
avoidpartialspends: false,
discardfee: 0,
},
advanced: {
mempool: {
mempoolfullrbf: false,
persistmempool: false,
maxmempool: 3012,
mempoolexpiry: 321,
},
peers: {
listen: false,
onlyconnect: false,
onlyonion: false,
addnode: [{ hostname: "google.com", port: 231 }],
},
dbcache: 123,
pruning: { mode: "automatic", size: 1234 },
blockfilters: {
blockfilterindex: false,
peerblockfilters: false,
},
bloomfilters: {
peerbloomfilters: false,
},
},
});
expect(() =>
checker.unsafeCast({
"peer-tor-address": "",
"rpc-tor-address": "",
rpc: {
enable: true,
username: "asdf",
password: "asdf",
advanced: {
auth: ["test:34$aa"],
serialversion: "non-segwit",
servertimeout: 12,
threads: 12,
workqueue: 12,
},
},
"zmq-enabled": false,
txindex: false,
wallet: {
enable: true,
avoidpartialspends: false,
discardfee: 0,
},
advanced: {
mempool: {
mempoolfullrbf: false,
persistmempool: false,
maxmempool: 3012,
mempoolexpiry: 321,
},
peers: {
listen: false,
onlyconnect: false,
onlyonion: false,
addnode: [{ hostname: "google", port: 231 }],
},
dbcache: 123,
pruning: { mode: "automatic", size: 1234 },
blockfilters: {
blockfilterindex: false,
peerblockfilters: false,
},
bloomfilters: {
peerbloomfilters: false,
},
},
})
).toThrow();
expect(() =>
checker.unsafeCast({
"peer-tor-address": "",
"rpc-tor-address": "",
rpc: {
enable: true,
username: "asdf",
password: "asdf",
advanced: {
auth: ["test34$aa"],
serialversion: "non-segwit",
servertimeout: 12,
threads: 12,
workqueue: 12,
},
},
"zmq-enabled": false,
txindex: false,
wallet: {
enable: true,
avoidpartialspends: false,
discardfee: 0,
},
advanced: {
mempool: {
mempoolfullrbf: false,
persistmempool: false,
maxmempool: 3012,
mempoolexpiry: 321,
},
peers: {
listen: false,
onlyconnect: false,
onlyonion: false,
addnode: [{ hostname: "google.com", port: 231 }],
},
dbcache: 123,
pruning: { mode: "automatic", size: 1234 },
blockfilters: {
blockfilterindex: false,
peerblockfilters: false,
},
bloomfilters: {
peerbloomfilters: false,
},
},
})
).toThrow();
expect(() =>
checker.unsafeCast({
"peer-tor-address": "",
"rpc-tor-address": "",
rpc: {
enable: true,
username: "asdf",
password: "asdf",
advanced: {
auth: ["test:34$aa"],
serialversion: "non-segwit",
servertimeout: 12,
threads: 12,
workqueue: 12,
},
},
"zmq-enabled": false,
txindex: false,
wallet: {
enable: true,
avoidpartialspends: false,
discardfee: 0,
},
advanced: {
mempool: {
mempoolfullrbf: false,
persistmempool: false,
maxmempool: 3012,
mempoolexpiry: 321,
},
peers: {
listen: false,
onlyconnect: false,
onlyonion: false,
addnode: [{ hostname: "google.com", port: 231 }],
},
dbcache: 123,
pruning: { mode: "automatic", size: "1234" },
blockfilters: {
blockfilterindex: false,
peerblockfilters: false,
},
bloomfilters: {
peerbloomfilters: false,
},
},
})
).toThrow();
checker.unsafeCast({
"peer-tor-address": "",
"rpc-tor-address": null,
rpc: {
enable: true,
username: "asdf",
password: "asdf",
advanced: {
auth: ["test:34$aa"],
serialversion: "non-segwit",
servertimeout: 12,
threads: 12,
workqueue: 12,
},
},
"zmq-enabled": false,
txindex: false,
wallet: {
enable: true,
avoidpartialspends: false,
discardfee: 0,
},
advanced: {
mempool: {
mempoolfullrbf: false,
persistmempool: false,
maxmempool: 3012,
mempoolexpiry: 321,
},
peers: {
listen: false,
onlyconnect: false,
onlyonion: false,
addnode: [{ hostname: "google.com", port: 231 }],
},
dbcache: 123,
pruning: { mode: "automatic", size: 1234 },
blockfilters: {
blockfilterindex: false,
peerblockfilters: false,
},
bloomfilters: {
peerbloomfilters: false,
},
},
});
});
}

271
utils/propertiesMatcher.ts Normal file
View File

@@ -0,0 +1,271 @@
import { matches } from "../dependencies.ts";
import { ConfigSpec, ValueSpecAny } from "../types.ts";
type TypeBoolean = "boolean";
type TypeString = "string";
type TypeNumber = "number";
type TypeObject = "object";
type TypeList = "list";
type TypeEnum = "enum";
type TypePointer = "pointer";
type TypeUnion = "union";
// prettier-ignore
// deno-fmt-ignore
type GuardDefaultNullable<A, Type> =
A extends { readonly default: unknown} ? Type :
A extends { readonly nullable: true} ? Type :
A extends {readonly nullable: false} ? Type | null | undefined :
Type
// prettier-ignore
// deno-fmt-ignore
type GuardNumber<A> =
A extends {readonly type:TypeNumber} ? GuardDefaultNullable<A, number> :
unknown
// prettier-ignore
// deno-fmt-ignore
type GuardString<A> =
A extends {readonly type:TypeString} ? GuardDefaultNullable<A, string> :
unknown
// prettier-ignore
// deno-fmt-ignore
type GuardBoolean<A> =
A extends {readonly type:TypeBoolean} ? GuardDefaultNullable<A, boolean> :
unknown
// prettier-ignore
// deno-fmt-ignore
type GuardObject<A> =
A extends {readonly type: TypeObject, readonly spec: infer B} ? (
B extends Record<string, unknown> ? {readonly [K in keyof B & string]: _<GuardAll<B[K]>>} :
{_error: "Invalid Spec"}
) :
unknown
// prettier-ignore
// deno-fmt-ignore
type GuardList<A> =
A extends {readonly type:TypeList, readonly subtype: infer B, spec?: {spec: infer C }} ? ReadonlyArray<GuardAll<Omit<A, "type" | "spec"> & ({type: B, spec: C})>> :
unknown
// prettier-ignore
// deno-fmt-ignore
type GuardPointer<A> =
A extends {readonly type:TypePointer} ? {_UNKNOWN: "Pointer"} :
unknown
// prettier-ignore
// deno-fmt-ignore
type GuardEnum<A> =
A extends {readonly type:TypeEnum, readonly values: ArrayLike<infer B>} ? GuardDefaultNullable<A, B> :
unknown
// prettier-ignore
// deno-fmt-ignore
type GuardUnion<A> =
A extends {readonly type:TypeUnion, readonly tag: {id: infer Id & string}, variants: infer Variants & Record<string, unknown>} ? {[K in keyof Variants]: {[keyType in Id & string]: K}&TypeFromProps<Variants[K]>}[keyof Variants] :
unknown
type _<T> = T;
export type GuardAll<A> = GuardNumber<A> &
GuardString<A> &
GuardBoolean<A> &
GuardObject<A> &
GuardList<A> &
GuardPointer<A> &
GuardUnion<A> &
GuardEnum<A>;
// prettier-ignore
// deno-fmt-ignore
export type TypeFromProps<A> =
A extends Record<string, unknown> ? {readonly [K in keyof A & string]: _<GuardAll<A[K]>>} :
unknown;
const isType = matches.shape({ type: matches.string });
const recordString = matches.dictionary([matches.string, matches.unknown]);
const matchDefault = matches.shape({ default: matches.unknown });
const matchNullable = matches.shape({ nullable: matches.literal(true) });
const matchPattern = matches.shape({ pattern: matches.string });
const rangeRegex = /(\[|\()(\*|(\d|\.)+),(\*|(\d|\.)+)(\]|\))/;
const matchRange = matches.shape({ range: matches.regex(rangeRegex) });
const matchIntegral = matches.shape({ integral: matches.literal(true) });
const matchSpec = matches.shape({ spec: recordString });
const matchSubType = matches.shape({ subtype: matches.string });
const matchUnion = matches.shape({
tag: matches.shape({ id: matches.string }),
variants: recordString,
});
const matchValues = matches.shape({
values: matches.arrayOf(matches.string),
});
function charRange(value = "") {
const split = value
.split("-")
.filter(Boolean)
.map((x) => x.charCodeAt(0));
if (split.length < 1) return null;
if (split.length === 1) return [split[0], split[0]];
return [split[0], split[1]];
}
/**
*
* @param generate.charset Pattern like "a-z" or "a-z,1-5"
* @param generate.len Length to make random variable
* @param param1
* @returns
*/
export function generateDefault(generate: { charset: string; len: number }, { random = () => Math.random() } = {}) {
const validCharSets: number[][] = generate.charset.split(",").map(charRange).filter(Array.isArray);
if (validCharSets.length === 0) throw new Error("Expecing that we have a valid charset");
const max = validCharSets.reduce((acc, x) => x.reduce((x, y) => Math.max(x, y), acc), 0);
let i = 0;
const answer: string[] = Array(generate.len);
while (i < generate.len) {
const nextValue = Math.round(random() * max);
const inRange = validCharSets.reduce(
(acc, [lower, upper]) => acc || (nextValue >= lower && nextValue <= upper),
false
);
if (!inRange) continue;
answer[i] = String.fromCharCode(nextValue);
i++;
}
return answer.join("");
}
function withPattern<A>(value: unknown) {
if (matchPattern.test(value)) return matches.regex(RegExp(value.pattern));
return matches.string;
}
export function matchNumberWithRange(range: string) {
const matched = rangeRegex.exec(range);
if (!matched) return matches.number;
const [, left, leftValue, , rightValue, , right] = matched;
return matches.number
.validate(
leftValue === "*" ? (_) => true : left === "[" ? (x) => x >= Number(leftValue) : (x) => x > Number(leftValue),
leftValue === "*" ? "any" : left === "[" ? `greaterThanOrEqualTo${leftValue}` : `greaterThan${leftValue}`
)
.validate(
rightValue === "*" ? (_) => true : right === "]" ? (x) => x <= Number(rightValue) : (x) => x < Number(rightValue),
rightValue === "*" ? "any" : right === "]" ? `lessThanOrEqualTo${rightValue}` : `lessThan${rightValue}`
);
}
function withIntegral(parser: matches.Parser<unknown, number>, value: unknown) {
if (matchIntegral.test(value)) {
return parser.validate(Number.isInteger, "isIntegral");
}
return parser;
}
function withRange(value: unknown) {
if (matchRange.test(value)) {
return matchNumberWithRange(value.range);
}
return matches.number;
}
const isGenerator = matches.shape({ charset: matches.string, len: matches.number }).test;
function defaultNullable<A>(parser: matches.Parser<unknown, A>, value: unknown) {
if (matchDefault.test(value)) {
if (isGenerator(value.default)) return parser.defaultTo(parser.unsafeCast(generateDefault(value.default)));
return parser.defaultTo(parser.unsafeCast(value.default));
}
if (matchNullable.test(value)) return parser.optional();
return parser;
}
/**
* ConfigSpec: Tells the UI how to ask for information, verification, and will send the service a config in a shape via the spec.
* ValueSpecAny: This is any of the values in a config spec.
*
* Use this when we want to convert a value spec any into a parser for what a config will look like
* @param value
* @returns
*/
export function guardAll<A extends ValueSpecAny>(value: A): matches.Parser<unknown, GuardAll<A>> {
if (!isType.test(value)) {
// deno-lint-ignore no-explicit-any
return matches.unknown as any;
}
switch (value.type) {
case "boolean":
// deno-lint-ignore no-explicit-any
return defaultNullable(matches.boolean, value) as any;
case "string":
// deno-lint-ignore no-explicit-any
return defaultNullable(withPattern(value), value) as any;
case "number":
return defaultNullable(
withIntegral(withRange(value), value),
value
// deno-lint-ignore no-explicit-any
) as any;
case "object":
if (matchSpec.test(value)) {
// deno-lint-ignore no-explicit-any
return defaultNullable(typeFromProps(value.spec), value) as any;
}
// deno-lint-ignore no-explicit-any
return matches.unknown as any;
case "list": {
const spec = (matchSpec.test(value) && value.spec) || {};
const rangeValidate = (matchRange.test(value) && matchNumberWithRange(value.range).test) || (() => true);
const subtype = matchSubType.unsafeCast(value).subtype;
return defaultNullable(
matches
// deno-lint-ignore no-explicit-any
.arrayOf(guardAll({ type: subtype, ...spec } as any))
.validate((x) => rangeValidate(x.length), "valid length"),
value
// deno-lint-ignore no-explicit-any
) as any;
}
case "enum":
if (matchValues.test(value)) {
return defaultNullable(
matches.literals(value.values[0], ...value.values),
value
// deno-lint-ignore no-explicit-any
) as any;
}
// deno-lint-ignore no-explicit-any
return matches.unknown as any;
case "pointer":
// deno-lint-ignore no-explicit-any
return matches.unknown as any;
case "union":
if (matchUnion.test(value)) {
return matches.some(
...Object.entries(value.variants).map(([variant, spec]) =>
matches.shape({ [value.tag.id]: matches.literal(variant) }).concat(typeFromProps(spec))
) // deno-lint-ignore no-explicit-any
) as any;
}
// deno-lint-ignore no-explicit-any
return matches.unknown as any;
}
// deno-lint-ignore no-explicit-any
return matches.unknown as any;
}
/**
* ConfigSpec: Tells the UI how to ask for information, verification, and will send the service a config in a shape via the spec.
* ValueSpecAny: This is any of the values in a config spec.
*
* Use this when we want to convert a config spec into a parser for what a config will look like
* @param valueDictionary
* @returns
*/
export function typeFromProps<A extends ConfigSpec>(valueDictionary: A): matches.Parser<unknown, TypeFromProps<A>> {
// deno-lint-ignore no-explicit-any
if (!recordString.test(valueDictionary)) return matches.unknown as any;
return matches.shape(
Object.fromEntries(Object.entries(valueDictionary).map(([key, value]) => [key, guardAll(value)]))
// deno-lint-ignore no-explicit-any
) as any;
}