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

feat: contract log query #1601

Draft
wants to merge 11 commits into
base: develop
Choose a base branch
from
14 changes: 14 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1006,6 +1006,20 @@ paths:
schema:
type: string
example: "SP6P4EJF0VG8V0RB3TQQKJBHDQKEF6NVRD1KZE3C.satoshibles"
- name: contains
in: query
description: Optional stringified JSON to select only results that contain the given JSON
required: false
schema:
type: string
example: '{"attachment":{"metadata":{"op":"name-register"}}}'
- name: filter_path
in: query
description: Optional [`jsonpath` expression](https://www.postgresql.org/docs/14/functions-json.html#FUNCTIONS-SQLJSON-PATH) to select only results that contain items matching the expression
required: false
schema:
type: string
example: '$.attachment.metadata?(@.op=="name-register")'
- name: limit
in: query
description: max number of contract events to fetch
Expand Down
14 changes: 14 additions & 0 deletions migrations/1680181889941_contract_log_json.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** @param { import("node-pg-migrate").MigrationBuilder } pgm */
exports.up = async pgm => {
pgm.addColumn('contract_logs', {
value_json: {
type: 'jsonb',
},
});
}

/** @param { import("node-pg-migrate").MigrationBuilder } pgm */
exports.down = pgm => {
pgm.dropIndex('contract_logs', 'value_json_path_ops_idx');
Copy link
Collaborator

Choose a reason for hiding this comment

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

This index is never created, is this an old line?

pgm.dropColumn('contract_logs', 'value_json');
}
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@
"getopts": "2.3.0",
"http-proxy-middleware": "2.0.1",
"jsonc-parser": "3.0.0",
"jsonpath-pg": "1.0.1",
"jsonrpc-lite": "2.2.0",
"lru-cache": "6.0.0",
"micro-base58": "0.5.1",
Expand Down
154 changes: 154 additions & 0 deletions src/api/query-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NextFunction, Request, Response } from 'express';
import { has0xPrefix, hexToBuffer, parseEventTypeStrings, isValidPrincipal } from './../helpers';
import { InvalidRequestError, InvalidRequestErrorType } from '../errors';
import { DbEventTypeId } from './../datastore/common';
import { jsonpathToAst, JsonpathAst, JsonpathItem } from 'jsonpath-pg';

function handleBadRequest(res: Response, next: NextFunction, errorMessage: string): never {
const error = new InvalidRequestError(errorMessage, InvalidRequestErrorType.bad_request);
Expand All @@ -11,6 +12,159 @@ function handleBadRequest(res: Response, next: NextFunction, errorMessage: strin
throw error;
}

export function validateJsonPathQuery<TRequired extends boolean>(
req: Request,
res: Response,
next: NextFunction,
paramName: string,
args: { paramRequired: TRequired; maxCharLength: number }
): TRequired extends true ? string | never : string | null {
if (!(paramName in req.query)) {
if (args.paramRequired) {
handleBadRequest(res, next, `Request is missing required "${paramName}" query parameter`);
} else {
return null as TRequired extends true ? string | never : string | null;
}
}
const jsonPathInput = req.query[paramName];
if (typeof jsonPathInput !== 'string') {
handleBadRequest(
res,
next,
`Unexpected type for '${paramName}' parameter: ${JSON.stringify(jsonPathInput)}`
);
}

const maxCharLength = args.maxCharLength;

if (jsonPathInput.length > maxCharLength) {
handleBadRequest(
res,
next,
`JsonPath parameter '${paramName}' is invalid: char length exceeded, max=${maxCharLength}, received=${jsonPathInput.length}`
);
}

let ast: JsonpathAst;
try {
ast = jsonpathToAst(jsonPathInput);
} catch (error) {
handleBadRequest(res, next, `JsonPath parameter '${paramName}' is invalid: ${error}`);
}
const astComplexity = calculateJsonpathComplexity(ast);
if (typeof astComplexity !== 'number') {
handleBadRequest(
res,
next,
`JsonPath parameter '${paramName}' is invalid: contains disallowed operation '${astComplexity.disallowedOperation}'`
);
}

return jsonPathInput;
}

/**
* Scan the a jsonpath expression to determine complexity.
* Disallow operations that could be used to perform expensive queries.
* See https://www.postgresql.org/docs/14/functions-json.html
*/
export function calculateJsonpathComplexity(
ast: JsonpathAst
): number | { disallowedOperation: string } {
let totalComplexity = 0;
const stack: JsonpathItem[] = [...ast.expr];

while (stack.length > 0) {
const item = stack.pop() as JsonpathItem;

switch (item.type) {
// Recursive lookup operations not allowed
case '[*]':
case '.*':
case '.**':
// string "starts with" operation not allowed
case 'starts with':
// string regex operations not allowed
case 'like_regex':
// Index range operations not allowed
case 'last':
// Type coercion not allowed
case 'is_unknown':
// Item method operations not allowed
case 'type':
case 'size':
case 'double':
case 'ceiling':
case 'floor':
case 'abs':
case 'datetime':
case 'keyvalue':
return { disallowedOperation: item.type };

// Array index accessor
case '[subscript]':
if (item.elems.some(elem => elem.to.length > 0)) {
// Range operations not allowed
return { disallowedOperation: '[n to m] array range accessor' };
} else {
totalComplexity += 1;
stack.push(...item.elems.flatMap(elem => elem.from));
}
break;

// Simple path navigation operations
case '$':
case '@':
break;

// Path literals
case '$variable':
case '.key':
case 'null':
case 'string':
case 'numeric':
case 'bool':
totalComplexity += 1;
break;

// Binary operations
case '&&':
case '||':
case '==':
case '!=':
case '<':
case '>':
case '<=':
case '>=':
case '+':
case '-':
case '*':
case '/':
case '%':
totalComplexity += 3;
stack.push(...item.left, ...item.right);
break;

// Unary operations
case '?':
case '!':
case '+unary':
case '-unary':
case 'exists':
totalComplexity += 2;
stack.push(...item.arg);
break;

default:
// @ts-expect-error - exhaustive switch
const unexpectedTypeID = item.type;
throw new Error(`Unexpected jsonpath expression type ID: ${unexpectedTypeID}`);
}
}

return totalComplexity;
}

export function booleanValueForParam(
req: Request,
res: Response,
Expand Down
34 changes: 32 additions & 2 deletions src/api/routes/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as express from 'express';
import { asyncHandler } from '../async-handler';
import { getPagingQueryLimit, parsePagingQueryInput, ResourceType } from '../pagination';
import { parseDbEvent } from '../controllers/db-controller';
import { parseTraitAbi } from '../query-helpers';
import { parseTraitAbi, validateJsonPathQuery } from '../query-helpers';
import { PgStore } from '../../datastore/pg-store';

export function createContractRouter(db: PgStore): express.Router {
Expand Down Expand Up @@ -50,14 +50,44 @@ export function createContractRouter(db: PgStore): express.Router {

router.get(
'/:contract_id/events',
asyncHandler(async (req, res) => {
asyncHandler(async (req, res, next) => {
const { contract_id } = req.params;
const limit = getPagingQueryLimit(ResourceType.Contract, req.query.limit);
const offset = parsePagingQueryInput(req.query.offset ?? 0);

const filterPath = validateJsonPathQuery(req, res, next, 'filter_path', {
paramRequired: false,
maxCharLength: 200,
});

const containsJsonQuery = req.query['contains'];
if (containsJsonQuery && typeof containsJsonQuery !== 'string') {
res.status(400).json({ error: `'contains' query param must be a string` });
return;
}
let containsJson: any | undefined;
const maxContainsJsonCharLength = 200;
if (containsJsonQuery) {
if (containsJsonQuery.length > maxContainsJsonCharLength) {
res.status(400).json({
error: `'contains' query param value exceeds ${maxContainsJsonCharLength} character limit`,
});
return;
}
try {
containsJson = JSON.parse(containsJsonQuery);
} catch (error) {
res.status(400).json({ error: `'contains' query param value must be valid JSON` });
return;
}
}

const eventsQuery = await db.getSmartContractEvents({
contractId: contract_id,
limit,
offset,
filterPath,
containsJson,
});
if (!eventsQuery.found) {
res.status(404).json({ error: `cannot find events for contract by ID: ${contract_id}` });
Expand Down
1 change: 1 addition & 0 deletions src/datastore/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1379,6 +1379,7 @@ export interface SmartContractEventInsertValues {
contract_identifier: string;
topic: string;
value: PgBytea;
value_json: string;
Copy link
Collaborator

Choose a reason for hiding this comment

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

It would be best if we set this as string | null IMO to avoid storing empty strings

}

export interface BurnchainRewardInsertValues {
Expand Down
Loading