From e22f03ad3b59b26570f8ce550384a8303242fd3f Mon Sep 17 00:00:00 2001 From: dtfiedler Date: Fri, 15 Mar 2024 19:03:30 -0600 Subject: [PATCH] feat(interactions): allow filtering interactions by function name Example: `curl https://localhost:3000/v1/contract/bLAgYxAdX2Ry-nt6aH2ixgvJXbpsEYm28NgJgyqfs-U?function=joinNetwork` --- docs/openapi.yaml | 8 ++ src/middleware/query.ts | 7 ++ src/routes/contract.ts | 17 ++++ tests/integration/arlocal/index.js | 5 ++ tests/integration/routes.test.ts | 121 +++++++++++++++++++++++++---- 5 files changed, 141 insertions(+), 17 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a7f14b7..b104972 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -63,6 +63,14 @@ components: description: Evaluate the contract at up to a specific sort key. Only applicable if blockHeight is not provided. schema: type: string + function: + name: function + in: query + required: false + description: Filter contract interactions by provided function name. + example: 'evolve' + schema: + type: string page: name: page in: query diff --git a/src/middleware/query.ts b/src/middleware/query.ts index 7de6527..f82bc11 100644 --- a/src/middleware/query.ts +++ b/src/middleware/query.ts @@ -30,6 +30,7 @@ export const queryMiddleware = async (ctx: KoaContext, next: Next) => { page = DEFAULT_PAGE, pageSize = DEFAULT_PAGE_SIZE, validity, + function: fn, } = ctx.query; logger.debug('Query params provided', { @@ -88,5 +89,11 @@ export const queryMiddleware = async (ctx: KoaContext, next: Next) => { ctx.state.validity = validity === 'true' || validity === '1'; } + // used for filtering functions on interactions + if (fn) { + logger.debug('Function provided', { fn }); + ctx.state.fn = fn; + } + return next(); }; diff --git a/src/routes/contract.ts b/src/routes/contract.ts index c16f16e..caae66c 100644 --- a/src/routes/contract.ts +++ b/src/routes/contract.ts @@ -76,6 +76,7 @@ export async function contractInteractionsHandler(ctx: KoaContext) { blockHeight: requestedBlockHeight, page: requestedPage, pageSize: requestedPageSize = 100, + fn: requestedFunction, } = ctx.state; const { contractTxId, address } = ctx.params; @@ -83,6 +84,7 @@ export async function contractInteractionsHandler(ctx: KoaContext) { contractTxId, sortKey: requestedSortKey, blockHeight: requestedBlockHeight, + requestedFunction, address, }); @@ -134,6 +136,7 @@ export async function contractInteractionsHandler(ctx: KoaContext) { ); mismatchedInteractionCount.inc(); } + return { ...interaction, valid: validity[id] ?? false, @@ -143,6 +146,20 @@ export async function contractInteractionsHandler(ctx: KoaContext) { }, ); + // TODO: allow other filters + if (requestedFunction) { + logger.debug('Filtering interactions by function', { + contractTxId, + sortKey: requestedSortKey, + blockHeight: requestedBlockHeight, + address, + requestedFunction, + }); + mappedInteractions = mappedInteractions.filter( + (interaction) => interaction.input?.function === requestedFunction, + ); + } + logger.debug('Sorting interactions', { contractTxId, sortKey: requestedSortKey, diff --git a/tests/integration/arlocal/index.js b/tests/integration/arlocal/index.js index 69b2a9d..0eebf80 100644 --- a/tests/integration/arlocal/index.js +++ b/tests/integration/arlocal/index.js @@ -5,6 +5,11 @@ export function handle(state, action) { const input = action.input; const caller = action.caller; + // test function + if (input.function === 'evolve') { + return { state }; + } + if (input.function === 'transfer') { const target = input.target; const qty = input.qty; diff --git a/tests/integration/routes.test.ts b/tests/integration/routes.test.ts index 668f01f..6087446 100644 --- a/tests/integration/routes.test.ts +++ b/tests/integration/routes.test.ts @@ -108,6 +108,35 @@ describe('Integration tests', () => { valid: true, id: writeInteraction!.originalTxId, }); + + await arweave.api.get('mine'); + + // another interaction to test filtering + const secondInteraction = await contract.writeInteraction( + { + function: 'evolve', + }, + { + disableBundling: true, + }, + ); + + const secondInteractionBlock = await arweave.blocks.getCurrent(); + contractInteractions.push({ + height: secondInteractionBlock.height, + input: { function: 'evolve' }, + owner: walletAddress, + timestamp: Math.floor(secondInteractionBlock.timestamp / 1000), + sortKey: await interactionSorter.createSortKey( + secondInteractionBlock.indep_hash, + secondInteraction!.originalTxId, + secondInteractionBlock.height, + ), + valid: true, + id: secondInteraction!.originalTxId, + }); + // reverse the interactions to match the service behavior + contractInteractions.reverse(); }); describe('general routes', () => { @@ -354,7 +383,7 @@ describe('Integration tests', () => { }); it('should only return interactions up to a provided sort key height', async () => { - const knownSortKey = contractInteractions[0].sortKey; + const knownSortKey = contractInteractions.slice(1)[0].sortKey; const { status, data } = await axios.get( `/v1/contract/${id}/interactions?sortKey=${knownSortKey}`, ); @@ -363,7 +392,7 @@ describe('Integration tests', () => { const { contractTxId, interactions, sortKey } = data; expect(contractTxId).to.equal(id); expect(sortKey).to.equal(knownSortKey); - expect(interactions).to.deep.equal([contractInteractions[0]]); + expect(interactions).to.deep.equal(contractInteractions.slice(1)); }); it('should return the first page of contract interactions when page and page size are provided', async () => { @@ -377,28 +406,30 @@ describe('Integration tests', () => { expect(pages).to.deep.equal({ page: 1, pageSize: 1, - totalPages: 1, + totalPages: contractInteractions.length, totalItems: contractInteractions.length, - hasNextPage: false, + hasNextPage: contractInteractions.length > 1, }); expect(contractTxId).to.equal(id); - expect(interactions).to.deep.equal([contractInteractions[0]]); + expect(interactions).to.deep.equal(contractInteractions.slice(0, 1)); }); it('should return an empty array of contract interactions when page is greater than the total number of pages', async () => { const { status, data } = await axios.get( - `/v1/contract/${id}/interactions?page=2&pageSize=1`, + `/v1/contract/${id}/interactions?page=${ + contractInteractions.length + 1 + }&pageSize=1`, ); expect(status).to.equal(200); expect(data).to.not.be.undefined; const { contractTxId, interactions, sortKey, pages } = data; expect(sortKey).to.not.be.undefined; expect(pages).to.deep.equal({ - page: 2, + page: contractInteractions.length + 1, pageSize: 1, - totalPages: 1, + totalPages: contractInteractions.length, totalItems: contractInteractions.length, - hasNextPage: false, + hasNextPage: contractInteractions.length > 2, }); expect(contractTxId).to.equal(id); expect(interactions).to.deep.equal([]); @@ -415,6 +446,32 @@ describe('Integration tests', () => { ); expect(status).to.equal(400); }); + + it('should return interactions that match a provided function', async () => { + const { status, data } = await axios.get( + `/v1/contract/${id}/interactions?function=evolve`, + ); + expect(status).to.equal(200); + expect(data).to.not.be.undefined; + const { contractTxId, interactions, sortKey } = data; + expect(contractTxId).to.equal(id); + expect(sortKey).not.be.undefined; + expect(interactions).to.deep.equal( + contractInteractions.filter((i) => i.input?.function === 'evolve'), + ); + }); + + it('should return an empty array for function query parameter that does not match any interactions', async () => { + const { status, data } = await axios.get( + `/v1/contract/${id}/interactions?function=fake`, + ); + expect(status).to.equal(200); + expect(data).to.not.be.undefined; + const { contractTxId, interactions, sortKey } = data; + expect(contractTxId).to.equal(id); + expect(sortKey).not.be.undefined; + expect(interactions).to.deep.equal([]); + }); }); describe('/:contractTxId/interactions/:address', () => { @@ -428,7 +485,35 @@ describe('Integration tests', () => { expect(contractTxId).to.equal(id); expect(sortKey).not.be.undefined; // TODO: filter out interactions specific to the wallet address - expect(interactions).to.deep.equal(contractInteractions); + expect(interactions).to.deep.equal( + contractInteractions.sort((a, b) => b.height - a.height), + ); + }); + + it('should return interactions that match a provided function', async () => { + const { status, data } = await axios.get( + `/v1/contract/${id}/interactions/${walletAddress}?function=evolve`, + ); + expect(status).to.equal(200); + expect(data).to.not.be.undefined; + const { contractTxId, interactions, sortKey } = data; + expect(contractTxId).to.equal(id); + expect(sortKey).not.be.undefined; + expect(interactions).to.deep.equal( + contractInteractions.filter((i) => i.input?.function === 'evolve'), + ); + }); + + it('should return an empty array for function query parameter that does not match any interactions', async () => { + const { status, data } = await axios.get( + `/v1/contract/${id}/interactions/${walletAddress}?function=fake`, + ); + expect(status).to.equal(200); + expect(data).to.not.be.undefined; + const { contractTxId, interactions, sortKey } = data; + expect(contractTxId).to.equal(id); + expect(sortKey).not.be.undefined; + expect(interactions).to.deep.equal([]); }); }); @@ -874,7 +959,9 @@ describe('Integration tests', () => { hasNextPage: false, }); expect(contractTxId).to.equal(id); - expect(interactions).to.deep.equal(contractInteractions); + expect(interactions).to.deep.equal( + contractInteractions.sort((a, b) => b.height - a.height), + ); }); it('should return the first page of interactions when page and page size are provided', async () => { const { status, data } = await axios.get( @@ -888,12 +975,12 @@ describe('Integration tests', () => { expect(pages).to.deep.equal({ page: 1, pageSize: 1, - totalPages: 1, + totalPages: contractInteractions.length, totalItems: contractInteractions.length, - hasNextPage: false, + hasNextPage: contractInteractions.length > 1, }); expect(contractTxId).to.equal(id); - expect(interactions).to.deep.equal([contractInteractions[0]]); + expect(interactions).to.deep.equal(contractInteractions.slice(0, 1)); }); it('should return the second page of interactions when page and page size are provided', async () => { const { status, data } = await axios.get( @@ -907,12 +994,12 @@ describe('Integration tests', () => { expect(pages).to.deep.equal({ page: 2, pageSize: 1, - totalPages: 1, + totalPages: contractInteractions.length, totalItems: contractInteractions.length, - hasNextPage: false, + hasNextPage: contractInteractions.length > 2, }); expect(contractTxId).to.equal(id); - expect(interactions).to.deep.equal([]); + expect(interactions).to.deep.equal(contractInteractions.slice(1)); }); it('should return a bad request error when invalid page size is provided', async () => { const { status } = await axios.get(