Skip to content
This repository has been archived by the owner on Oct 7, 2024. It is now read-only.

Commit

Permalink
fix: add exactOptional() superstruct type (#100)
Browse files Browse the repository at this point in the history
  • Loading branch information
danroc authored Sep 6, 2023
1 parent 158dada commit 0d7c2bf
Show file tree
Hide file tree
Showing 12 changed files with 673 additions and 61 deletions.
1 change: 1 addition & 0 deletions .github/workflows/build-lint-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ jobs:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- run: yarn --immutable --immutable-cache
- run: yarn build
- run: yarn test
- name: Require clean working directory
shell: bash
Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ module.exports = {
collectCoverage: true,

// An array of glob patterns indicating a set of files for which coverage information should be collected
collectCoverageFrom: ['./src/**/*.ts'],
collectCoverageFrom: ['./src/**/*.ts', '!./src/**/*.test-d.ts'],

// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',
Expand Down
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,17 @@
"lint:fix": "yarn lint:eslint --fix && yarn lint:constraints --fix && yarn lint:misc --write && yarn lint:dependencies && yarn lint:changelog",
"lint:misc": "prettier '**/*.json' '**/*.md' '!CHANGELOG.md' '**/*.yml' '!.yarnrc.yml' --ignore-path .gitignore --no-error-on-unmatched-pattern",
"prepack": "./scripts/prepack.sh",
"test": "jest && jest-it-up",
"test": "yarn test:source && yarn test:types",
"test:source": "jest && jest-it-up",
"test:types": "tsd",
"test:watch": "jest --watch"
},
"dependencies": {
"@metamask/providers": "^12.0.0",
"@metamask/rpc-methods": "^0.38.1-flask.1",
"@metamask/snaps-controllers": "^0.38.2-flask.1",
"@metamask/snaps-utils": "^0.38.2-flask.1",
"@metamask/utils": "^8.0.0",
"@metamask/utils": "^8.1.0",
"@types/uuid": "^9.0.1",
"superstruct": "^1.0.3",
"uuid": "^9.0.0"
Expand Down Expand Up @@ -73,6 +75,7 @@
"rimraf": "^3.0.2",
"ts-jest": "^28.0.7",
"ts-node": "^10.7.0",
"tsd": "^0.29.0",
"typedoc": "^0.23.15",
"typescript": "~4.8.4"
},
Expand All @@ -96,5 +99,8 @@
"@metamask/rpc-methods>@metamask/permission-controller>@metamask/controller-utils>ethereumjs-util>ethereum-cryptography>keccak": false,
"@metamask/rpc-methods>@metamask/permission-controller>@metamask/controller-utils>ethereumjs-util>ethereum-cryptography>secp256k1": false
}
},
"tsd": {
"directory": "src"
}
}
31 changes: 9 additions & 22 deletions src/JsonRpcRequest.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,17 @@
import { JsonStruct } from '@metamask/utils';
import type { Infer } from 'superstruct';
import {
array,
literal,
nullable,
number,
object,
record,
string,
union,
} from 'superstruct';
import { array, literal, number, record, string, union } from 'superstruct';

const Common = {
import { exactOptional, object } from './superstruct';

export const JsonRpcRequestStruct = object({
jsonrpc: literal('2.0'),
id: nullable(union([string(), number()])),
id: union([string(), number(), literal(null)]),
method: string(),
};

const Params = {
params: union([array(JsonStruct), record(string(), JsonStruct)]),
};

export const JsonRpcRequestStruct = union([
object({ ...Common }),
object({ ...Common, ...Params }),
]);
params: exactOptional(
union([array(JsonStruct), record(string(), JsonStruct)]),
),
});

/**
* JSON-RPC request type.
Expand Down
17 changes: 7 additions & 10 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import {
array,
enums,
literal,
object,
record,
string,
union,
nullable,
} from 'superstruct';

import { exactOptional, object } from './superstruct';
import { UuidStruct } from './utils';

/**
Expand Down Expand Up @@ -98,15 +98,12 @@ export const KeyringRequestStruct = object({
/**
* Inner request sent by the client application.
*/
request: union([
object({
method: string(),
}),
object({
method: string(),
params: union([array(JsonStruct), record(string(), JsonStruct)]),
}),
]),
request: object({
method: string(),
params: exactOptional(
union([array(JsonStruct), record(string(), JsonStruct)]),
),
}),
});

/**
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ export * from './rpc-handler';
export * from './KeyringSnapControllerClient';
export * from './KeyringSnapRpcClient';
export * from './internal';
export * from './snap-utils';
export * from './superstruct';
9 changes: 5 additions & 4 deletions src/internal/types.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import type { Infer } from 'superstruct';
import { boolean, object, optional, string, number } from 'superstruct';
import { boolean, string, number } from 'superstruct';

import { KeyringAccountStruct } from '../api';
import { exactOptional, object } from '../superstruct';

export const InternalAccountStruct = object({
...KeyringAccountStruct.schema,
metadata: object({
snap: optional(
name: string(),
snap: exactOptional(
object({
id: string(),
enabled: boolean(),
name: string(),
}),
),
name: string(),
lastSelected: optional(number()),
lastSelected: exactOptional(number()),
keyring: object({
type: string(),
}),
Expand Down
4 changes: 3 additions & 1 deletion src/rpc-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ describe('keyringRpcDispatcher', () => {

await expect(
handleKeyringRequest(keyring, request as unknown as JsonRpcRequest),
).rejects.toThrow('Expected the value to satisfy a union of');
).rejects.toThrow(
'At path: method -- Expected a string, but received: undefined',
);
});

it('should call keyring_getAccount', async () => {
Expand Down
20 changes: 20 additions & 0 deletions src/superstruct.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Infer } from 'superstruct';
import { boolean, number, optional, string } from 'superstruct';
import { expectAssignable, expectNotAssignable } from 'tsd';

import { exactOptional, object } from '.';

const exactOptionalObject = object({
a: number(),
b: optional(string()),
c: exactOptional(boolean()),
});

type ExactOptionalObject = Infer<typeof exactOptionalObject>;

expectAssignable<ExactOptionalObject>({ a: 0 });
expectAssignable<ExactOptionalObject>({ a: 0, b: 'test' });
expectAssignable<ExactOptionalObject>({ a: 0, b: 'test', c: true });
expectAssignable<ExactOptionalObject>({ a: 0, b: undefined });
expectNotAssignable<ExactOptionalObject>({ a: 0, b: 'test', c: 0 });
expectNotAssignable<ExactOptionalObject>({ a: 0, b: 'test', c: undefined });
72 changes: 72 additions & 0 deletions src/superstruct.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { is, literal, max, number, string, union } from 'superstruct';

import { exactOptional, object } from '.';

describe('superstruct', () => {
describe('exactOptional', () => {
const simpleStruct = object({
foo: exactOptional(string()),
});

it.each([
{ struct: simpleStruct, obj: {}, expected: true },
{ struct: simpleStruct, obj: { foo: undefined }, expected: false },
{ struct: simpleStruct, obj: { foo: 'hi' }, expected: true },
{ struct: simpleStruct, obj: { bar: 'hi' }, expected: false },
{ struct: simpleStruct, obj: { foo: 1 }, expected: false },
])(
'returns $expected for is($obj, <struct>)',
({ struct, obj, expected }) => {
expect(is(obj, struct)).toBe(expected);
},
);

const nestedStruct = object({
foo: object({
bar: exactOptional(string()),
}),
});

it.each([
{ struct: nestedStruct, obj: { foo: {} }, expected: true },
{ struct: nestedStruct, obj: { foo: { bar: 'hi' } }, expected: true },
{
struct: nestedStruct,
obj: { foo: { bar: undefined } },
expected: false,
},
])(
'returns $expected for is($obj, <struct>)',
({ struct, obj, expected }) => {
expect(is(obj, struct)).toBe(expected);
},
);

const structWithUndef = object({
foo: exactOptional(union([string(), literal(undefined)])),
});

it.each([
{ struct: structWithUndef, obj: {}, expected: true },
{ struct: structWithUndef, obj: { foo: undefined }, expected: true },
{ struct: structWithUndef, obj: { foo: 'hi' }, expected: true },
{ struct: structWithUndef, obj: { bar: 'hi' }, expected: false },
{ struct: structWithUndef, obj: { foo: 1 }, expected: false },
])(
'returns $expected for is($obj, <struct>)',
({ struct, obj, expected }) => {
expect(is(obj, struct)).toBe(expected);
},
);

it('should support refinements', () => {
const struct = object({
foo: exactOptional(max(number(), 0)),
});

expect(is({ foo: 0 }, struct)).toBe(true);
expect(is({ foo: -1 }, struct)).toBe(true);
expect(is({ foo: 1 }, struct)).toBe(false);
});
});
});
105 changes: 105 additions & 0 deletions src/superstruct.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/* eslint-disable @typescript-eslint/naming-convention */
import {
type Infer,
type Context,
Struct,
object as stObject,
} from 'superstruct';
import type {
ObjectSchema,
OmitBy,
Optionalize,
PickBy,
Simplify,
} from 'superstruct/dist/utils';

declare const ExactOptionalSymbol: unique symbol;

export type ExactOptionalTag = {
type: typeof ExactOptionalSymbol;
};

/**
* Exclude a type from the properties of a type.
*
* ```ts
* type Foo = { a: string | null; b: number };
* type Bar = ExcludeType<Foo, null>;
* // Bar = { a: string, b: number }
* ```
*/
export type ExcludeType<T, V> = {
[K in keyof T]: Exclude<T[K], V>;
};

/**
* Make optional all properties that have the `ExactOptionalTag` type.
*
* ```ts
* type Foo = { a: string | ExactOptionalTag; b: number};
* type Bar = ExactOptionalize<Foo>;
* // Bar = { a?: string; b: number}
* ```
*/
export type ExactOptionalize<S extends object> = OmitBy<S, ExactOptionalTag> &
Partial<ExcludeType<PickBy<S, ExactOptionalTag>, ExactOptionalTag>>;

/**
* Infer a type from an superstruct object schema.
*/
export type ObjectType<S extends ObjectSchema> = Simplify<
ExactOptionalize<Optionalize<{ [K in keyof S]: Infer<S[K]> }>>
>;

/**
* Change the return type of a superstruct object struct to support exact
* optional properties.
*
* @param schema - The object schema.
* @returns A struct representing an object with a known set of properties.
*/
export function object<S extends ObjectSchema>(
schema: S,
): Struct<ObjectType<S>, S> {
return stObject(schema) as any;
}

/**
* Check if the current property is present in its parent object.
*
* @param ctx - The context to check.
* @returns `true` if the property is present, `false` otherwise.
*/
function hasOptional(ctx: Context): boolean {
const property: string = ctx.path[ctx.path.length - 1];
const parent: Record<string, unknown> = ctx.branch[ctx.branch.length - 2];

return property in parent;
}

/**
* Augment a struct to allow exact-optional values. Exact-optional values can
* be omitted but cannot be `undefined`.
*
* ```ts
* const foo = object({ bar: exactOptional(string()) });
* type Foo = Infer<typeof foo>;
* // Foo = { bar?: string }
* ```
*
* @param struct - The struct to augment.
* @returns The augmented struct.
*/
export function exactOptional<T, S>(
struct: Struct<T, S>,
): Struct<T | ExactOptionalTag, S> {
return new Struct({
...struct,

validator: (value, ctx) =>
!hasOptional(ctx) || struct.validator(value, ctx),

refiner: (value, ctx) =>
!hasOptional(ctx) || struct.refiner(value as T, ctx),
});
}
Loading

0 comments on commit 0d7c2bf

Please sign in to comment.