Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JsonApiCondition #550

Merged
merged 8 commits into from
Jul 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/taco/src/conditions/base/json-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { z } from 'zod';

import { Condition } from '../condition';
import {
OmitConditionType,
returnValueTestSchema,
} from '../shared';

export const JsonApiConditionType = 'json-api';

export const JsonApiConditionSchema = z.object({
conditionType: z.literal(JsonApiConditionType).default(JsonApiConditionType),
endpoint: z.string().url(),
parameters: z.record(z.string(), z.unknown()).optional(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does parameters take any primitive type as a parameter, or just string and object? Looking at the tests etc., I'm under the impression it's object only.

Suggested change
parameters: z.record(z.string(), z.unknown()).optional(),
parameters: z.record(z.object()).optional(),

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I follow. The example lingo from the issue is:

    "parameters": {
        "ids": "ethereum",
        "vs_currencies": "usd",
    },

so i guess it could be z.record(z.string(), z.object()).optional(),

query: z.string().optional(),
Copy link
Member

@KPrasch KPrasch Jul 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how to implement this, but this field is expected to be a valid JSONPath query. It may be helpful to consumers to have a taco-web level validation of these queries to prevent invalid conditions from being created. Perhaps this can be done with a jsonpath library or a rudimentary regex.

Copy link
Contributor Author

@theref theref Jul 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as i mentioned on the call, any string is a valid json path. The path only becomes valid/invalid when there is some json associated with it, which will only happen at decryption time

also here https://github.com/nucypher/sprints/issues/23#issuecomment-2238759313

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Json path strings have a specific format/syntax. It would be good to ensure that the format/syntax is correct at construction time. Perhaps https://www.npmjs.com/package/jsonpath / https://www.npmjs.com/package/@types/jsonpath can be of use for validation. Here's an example we did for SIWE messages - https://github.com/nucypher/taco-web/pull/527/files#diff-979bf898c06acdb90eb18e31377b0d4423990d3299e14a7596b3efca73272b49R20.

Copy link
Member

@KPrasch KPrasch Jul 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as i mentioned on the call

Ah, perhaps I missed that comment, my bad.

The path only becomes valid/invalid when there is some json associated with it

This is a good point for syntactically valid expressions, however...

any string is a valid json path

This is not true since it's possible to create syntactically invalid json path expressions. Nonetheless, this is not a blocking issue but does represent a small improvement in developer experience. Here are some reasonable (imo) examples:

$.store.book[?(@.price < ]
The filter expression is incomplete and not properly closed.

$[store][book]
Incorrect usage of square brackets. Proper syntax is $.store.book or $.store['book'].
This one is actually parsable but does not follow the spec, I'll let it slide :-)

$.store.book[*
Asterisk wildcard is not properly closed with a bracket.

Copy link
Contributor Author

@theref theref Jul 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is incorrect, without any json to compare with $.store.book[?(@.price < ] is a valid json path, because the key in the dictionary could be the string "book[?(@.price < ]"

I have gone through this with some of the json path libraries already

$[store][book] again, the key could be the string "[store][book]"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, i'll add it back in and some tests

Copy link
Member

@KPrasch KPrasch Jul 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's a watered-down example of how nodes will attempt to process this query, and fail due to syntactic error.

In [56]: import jsonpath_ng

In [57]: jsonpath_ng.parse('$.store.book[?(@.price < ]')
...
JsonPathLexerError: Error on line 1, col 13: Unexpected character: ? 

corrected expression with enclosing quotes and escapes:

In [58]: jsonpath_ng.parse('$.store[\'book[?(@.price < ]\']')
Out[58]: Child(Child(Root(), Fields('store')), Fields('book[?(@.price < ]'))

Another example:

In [61]: jsonpath_ng.parse('$.store.book[*')
...
JsonPathParserError: Parse error near the end of string!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah i understand this - my point is that if the field Fields('book[?(@.price < ]') exists then it's a valid json path

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe when I had this in my testing examples where wrong, but I've added it back in 4546b2f
Currently it fails, due to the path being valid without any json to compare with

Copy link
Member

@KPrasch KPrasch Jul 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah i understand this - my point is that if the field Fields('book[?(@.price < ]') exists then it's a valid json path

It's only syntactically valid if the expression has the correct enclosing quotes ('$.store[\'book[?(@.price < ]\']'). Also consider unclosed square brackets for example ('$.store.book[*' is syntactically invalid but can be made valid by adding the appropriate quote and square bracket '$.store[\'book[*\']' is valid).

My point is: it is possible to create syntactically invalid jsonpath expressions. There's nothing we can do to validate that the expression will be valid against a JSON endpoint's response structure but we can easily detect syntax errors.

returnValueTest: returnValueTestSchema, // Update to allow multiple return values after expanding supported methods
});

export type JsonApiConditionProps = z.infer<typeof JsonApiConditionSchema>;

export class JsonApiCondition extends Condition {
constructor(value: OmitConditionType<JsonApiConditionProps>) {
super(JsonApiConditionSchema, {
conditionType: JsonApiConditionType,
...value,
});
}
}
7 changes: 7 additions & 0 deletions packages/taco/src/conditions/condition-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import {
ContractConditionProps,
ContractConditionType,
} from './base/contract';
import {
JsonApiCondition,
JsonApiConditionProps,
JsonApiConditionType,
} from './base/json-api';
import { RpcCondition, RpcConditionProps, RpcConditionType } from './base/rpc';
import {
TimeCondition,
Expand Down Expand Up @@ -30,6 +35,8 @@ export class ConditionFactory {
return new ContractCondition(props as ContractConditionProps);
case CompoundConditionType:
return new CompoundCondition(props as CompoundConditionProps);
case JsonApiConditionType:
return new JsonApiCondition(props as JsonApiConditionProps);
default:
throw new Error(ERR_INVALID_CONDITION_TYPE(props.conditionType));
}
Expand Down
63 changes: 63 additions & 0 deletions packages/taco/test/conditions/base/json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { describe, expect, it } from 'vitest';

import {
JsonApiCondition,
JsonApiConditionSchema,
} from '../../../src/conditions/base/json-api';
import { testJsonApiConditionObj } from '../../test-utils';

describe('JsonApiCondition', () => {
describe('validation', () => {
it('accepts a valid schema', () => {
const result = JsonApiCondition.validate(
JsonApiConditionSchema,
testJsonApiConditionObj,
);

expect(result.error).toBeUndefined();
expect(result.data).toEqual(testJsonApiConditionObj);
});

it('rejects an invalid schema', () => {
const badJsonApiObj = {
...testJsonApiConditionObj,
endpoint: 'not-a-url',
};

const result = JsonApiCondition.validate(JsonApiConditionSchema, badJsonApiObj);

expect(result.error).toBeDefined();
expect(result.data).toBeUndefined();
expect(result.error?.format()).toMatchObject({
endpoint: {
_errors: ['Invalid url'],
},
});
});

describe('parameters', () => {
it('accepts conditions without query path', () => {
const { query, ...noQueryObj} = testJsonApiConditionObj;
const result = JsonApiCondition.validate(
JsonApiConditionSchema,
noQueryObj
);

expect(result.error).toBeUndefined();
expect(result.data).toEqual(noQueryObj);
});

it('accepts conditions without parameters', () => {
const { query, ...noParamsObj} = testJsonApiConditionObj;
const result = JsonApiCondition.validate(
JsonApiConditionSchema,
noParamsObj
);

expect(result.error).toBeUndefined();
expect(result.data).toEqual(noParamsObj);
});
});
});
});
20 changes: 20 additions & 0 deletions packages/taco/test/conditions/condition-expr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ContractCondition,
ContractConditionProps,
} from '../../src/conditions/base/contract';
import { JsonApiCondition } from '../../src/conditions/base/json-api';
import { RpcCondition, RpcConditionType } from '../../src/conditions/base/rpc';
import {
TimeCondition,
Expand All @@ -20,6 +21,7 @@ import { ERC721Balance } from '../../src/conditions/predefined/erc721';
import {
testContractConditionObj,
testFunctionAbi,
testJsonApiConditionObj,
testReturnValueTest,
testRpcConditionObj,
testTimeConditionObj,
Expand Down Expand Up @@ -56,6 +58,7 @@ describe('condition set', () => {

const rpcCondition = new RpcCondition(testRpcConditionObj);
const timeCondition = new TimeCondition(testTimeConditionObj);
const jsonApiCondition = new JsonApiCondition(testJsonApiConditionObj);
const compoundCondition = new CompoundCondition({
operator: 'and',
operands: [
Expand Down Expand Up @@ -401,6 +404,23 @@ describe('condition set', () => {
expect(conditionExprFromJson.condition).toBeInstanceOf(RpcCondition);
});

it('json api condition serialization', () => {
const conditionExpr = new ConditionExpression(jsonApiCondition);

const conditionExprJson = conditionExpr.toJson();
expect(conditionExprJson).toBeDefined();
expect(conditionExprJson).toContain('endpoint');
expect(conditionExprJson).toContain('https://_this_would_totally_fail.com');
expect(conditionExprJson).toContain('parameters');
expect(conditionExprJson).toContain('query');
expect(conditionExprJson).toContain('$.ethereum.usd');
expect(conditionExprJson).toContain('returnValueTest');

const conditionExprFromJson = ConditionExpression.fromJSON(conditionExprJson);
expect(conditionExprFromJson).toBeDefined();
expect(conditionExprFromJson.condition).toBeInstanceOf(JsonApiCondition);
});

it('compound condition serialization', () => {
const conditionExpr = new ConditionExpression(compoundCondition);
const compoundConditionObj = compoundCondition.toObj();
Expand Down
15 changes: 15 additions & 0 deletions packages/taco/test/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ import {
ContractConditionType,
FunctionAbiProps,
} from '../src/conditions/base/contract';
import {
JsonApiConditionProps,
JsonApiConditionType
} from '../src/conditions/base/json-api';
import {
RpcConditionProps,
RpcConditionType,
Expand Down Expand Up @@ -222,6 +226,17 @@ export const testTimeConditionObj: TimeConditionProps = {
chain: TEST_CHAIN_ID,
};

export const testJsonApiConditionObj = {
conditionType: JsonApiConditionType,
endpoint: 'https://_this_would_totally_fail.com',
parameters: {
'ids': 'ethereum',
'vs_currencies': 'usd',
},
query: '$.ethereum.usd',
returnValueTest: testReturnValueTest,
};

export const testRpcConditionObj: RpcConditionProps = {
conditionType: RpcConditionType,
chain: TEST_CHAIN_ID,
Expand Down
Loading