diff --git a/src/Middleware.ts b/src/Middleware.ts index 56baca3106..2d669e24cd 100644 --- a/src/Middleware.ts +++ b/src/Middleware.ts @@ -1,4 +1,4 @@ -import { OperationOptions } from '@azure/core-client'; +import { OperationArguments, OperationOptions, OperationSpec } from '@azure/core-client'; import { userAgentPolicyName, setClientRequestIdPolicyName } from '@azure/core-rest-pipeline'; import { genRequestQueuesPolicy, genCombineGetRequestsPolicy, genErrorFormatterPolicy, @@ -7,6 +7,7 @@ import { import { Middleware as MiddlewareApi, MiddlewareOptionalParams, ErrorResponse } from './apis/middleware'; import { operationSpecs } from './apis/middleware/middleware'; import { IllegalArgumentError, InternalError } from './utils/errors'; +import { MiddlewarePage, isMiddlewareRawPage } from './utils/MiddlewarePage'; export default class Middleware extends MiddlewareApi { /** @@ -97,4 +98,13 @@ export default class Middleware extends MiddlewareApi { })), }); } + + override async sendOperationRequest( + operationArguments: OperationArguments, + operationSpec: OperationSpec, + ): Promise { + const response = await super.sendOperationRequest(operationArguments, operationSpec); + if (!isMiddlewareRawPage(response)) return response as T; + return new MiddlewarePage(response, this) as T; + } } diff --git a/src/index-browser.ts b/src/index-browser.ts index b4a0e6e519..b060954d3b 100644 --- a/src/index-browser.ts +++ b/src/index-browser.ts @@ -72,6 +72,7 @@ export { default as MiddlewareSubscriber, MiddlewareSubscriberError, MiddlewareSubscriberDisconnected, } from './MiddlewareSubscriber'; export { default as Middleware } from './Middleware'; +export { MiddlewarePageMissed } from './utils/MiddlewarePage'; export { default as connectionProxy } from './aepp-wallet-communication/connection-proxy'; export { diff --git a/src/utils/MiddlewarePage.ts b/src/utils/MiddlewarePage.ts new file mode 100644 index 0000000000..e2b7f8528a --- /dev/null +++ b/src/utils/MiddlewarePage.ts @@ -0,0 +1,66 @@ +/* eslint-disable max-classes-per-file */ +import type Middleware from '../Middleware'; +import { BaseError } from './errors'; + +export interface MiddlewareRawPage { + data: unknown[]; + next: string | null; + prev: string | null; +} + +export function isMiddlewareRawPage(maybePage: unknown): maybePage is MiddlewareRawPage { + const testPage = maybePage as MiddlewareRawPage; + return testPage?.data != null && Array.isArray(testPage.data) + && 'next' in testPage + && 'prev' in testPage; +} + +/** + * @category exception + */ +export class MiddlewarePageMissed extends BaseError { + constructor(isNext: boolean) { + super(`There is no ${isNext ? 'next' : 'previous'} page`); + this.name = 'MiddlewarePageMissed'; + } +} + +/** + * A wrapper around the middleware's page allowing to get the next/previous pages. + */ +export class MiddlewarePage { + readonly data: Item[]; + + readonly nextPath: null | string; + + readonly prevPath: null | string; + + readonly #middleware: Middleware; + + constructor(rawPage: MiddlewareRawPage, middleware: Middleware) { + this.data = rawPage.data as Item[]; + this.nextPath = rawPage.next; + this.prevPath = rawPage.prev; + this.#middleware = middleware; + } + + /** + * Get the next page. + * Check the presence of `nextPath` to not fall outside existing pages. + * @throws MiddlewarePageMissed + */ + async next(): Promise> { + if (this.nextPath == null) throw new MiddlewarePageMissed(true); + return this.#middleware.requestByPath(this.nextPath); + } + + /** + * Get the previous page. + * Check the presence of `prevPath` to not fall outside existing pages. + * @throws MiddlewarePageMissed + */ + async prev(): Promise> { + if (this.prevPath == null) throw new MiddlewarePageMissed(false); + return this.#middleware.requestByPath(this.prevPath); + } +} diff --git a/test/integration/Middleware.ts b/test/integration/Middleware.ts index ea0dead34e..1e37ad3fd0 100644 --- a/test/integration/Middleware.ts +++ b/test/integration/Middleware.ts @@ -1,10 +1,11 @@ import { describe, before, it } from 'mocha'; import { expect } from 'chai'; import resetMiddleware, { presetAccount1Address, presetAccount2Address } from './reset-middleware'; -import { IllegalArgumentError, Middleware } from '../../src'; +import { IllegalArgumentError, Middleware, MiddlewarePageMissed } from '../../src'; import { assertNotNull } from '../utils'; import { pause } from '../../src/utils/other'; import { Activity } from '../../src/apis/middleware'; +import { MiddlewarePage } from '../../src/utils/MiddlewarePage'; function copyFields( target: { [key: string]: any }, @@ -61,7 +62,7 @@ describe('Middleware API', () => { describe('blocks', () => { it('gets key blocks', async () => { const res = await middleware.getKeyBlocks({ limit: 15 }); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ beneficiary: 'ak_11111111111111111111111111111111273Yts', hash: 'kh_nvmdByyHT8513zwVwxQ1tTsKbgfgdp1LX43jHj3ujb2AvDSh5', @@ -79,7 +80,7 @@ describe('Middleware API', () => { }], next: null, prev: null, - }; + }, middleware); expectedRes.data.unshift(...res.data.slice(0, -1)); expect(res).to.be.eql(expectedRes); }); @@ -111,7 +112,7 @@ describe('Middleware API', () => { describe('transactions', () => { it('gets account activities', async () => { const res = await middleware.getAccountActivities(presetAccount1Address); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ blockHash: 'mh_f4S91p7y6hojhGhPHwzoXdjvZVWcuaBg759BDUzHDsQmYnC4o', blockTime: 1721994542947, @@ -257,7 +258,7 @@ describe('Middleware API', () => { }], next: null, prev: null, - }; + }, middleware); expectedRes.data.forEach((item, idx) => { copyFields(item, res.data[idx], ['blockHash', 'blockTime']); copyFields( @@ -271,7 +272,7 @@ describe('Middleware API', () => { it('gets transactions', async () => { const res = await middleware.getTransactions({ limit: 15 }); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ blockHash: 'mh_2nWwtjNCnjUYMqGfgfrt8MsnvRYuY8ZdET31RXTa4jFBdzEKF8', blockHeight: 1, @@ -296,12 +297,13 @@ describe('Middleware API', () => { }], next: null, prev: null, - }; + }, middleware); const tx = res.data.at(-1); assertNotNull(tx); - res.data = [tx]; + res.data.length = 0; + res.data.push(tx); copyFields(expectedRes.data[0], res.data[0], ['blockHash', 'microTime']); - expect(res.data).to.be.eql(expectedRes.data); + expect(res).to.be.eql(expectedRes); }); it('gets transactions count', async () => { @@ -312,7 +314,7 @@ describe('Middleware API', () => { it('gets transfers', async () => { const res = await middleware.getTransfers(); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ accountId: 'ak_mm92WC5DaSxLfWouNABCU9Uo1bDMFEXgbbnWU8n8o9u1e3qQp', amount: 570288700000000000000n, @@ -324,7 +326,7 @@ describe('Middleware API', () => { }], next: null, prev: null, - }; + }, middleware); expectedRes.data.push(...res.data.slice(1)); copyFields(expectedRes.data[0], res.data[0], ['refTxType']); expect(res).to.be.eql(expectedRes); @@ -334,7 +336,7 @@ describe('Middleware API', () => { describe('contracts', () => { it('gets contract calls', async () => { const res = await middleware.getContractCalls(); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ function: 'Chain.spend', height: 9, @@ -357,14 +359,14 @@ describe('Middleware API', () => { }], next: null, prev: null, - }; + }, middleware); copyFields(expectedRes.data[0], res.data[0], ['blockHash']); expect(res).to.be.eql(expectedRes); }); it('gets contract logs', async () => { const res = await middleware.getContractLogs(); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ args: [ '43', @@ -386,7 +388,7 @@ describe('Middleware API', () => { }], next: null, prev: null, - }; + }, middleware); copyFields(expectedRes.data[0], res.data[0], ['blockHash', 'blockTime']); expect(res).to.be.eql(expectedRes); }); @@ -423,7 +425,7 @@ describe('Middleware API', () => { describe('names', () => { it('gets names', async () => { const res = await middleware.getNames(); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ active: true, hash: 'nm_2VSJFCVStB8ZdkLWcyd4adywYoyqYNzMt9Td924Jf8ESi94Nni', @@ -447,7 +449,7 @@ describe('Middleware API', () => { }], next: null, prev: null, - }; + }, middleware); expectedRes.data.push(res.data[1]); copyFields( expectedRes.data[0], @@ -465,7 +467,7 @@ describe('Middleware API', () => { it('gets name claims', async () => { const res = await middleware.getNameClaims('123456789012345678901234567801.chain'); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ activeFrom: 5, blockHash: 'mh_2MYrB5Qjb4NCYZMVmbqnazacY76gGzNgEjW2VnEKzovDTky8fD', @@ -485,14 +487,14 @@ describe('Middleware API', () => { }], next: null, prev: null, - }; + }, middleware); copyFields(expectedRes.data[0], res.data[0], ['blockHash']); expect(res).to.be.eql(expectedRes); }); it('gets name updates', async () => { const res = await middleware.getNameUpdates('123456789012345678901234567801.chain'); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ activeFrom: 5, blockHash: 'mh_2G1nKcenAWtgqJywzmAFLXajZESRySnapUmF4JAboyekmwjBxa', @@ -521,14 +523,14 @@ describe('Middleware API', () => { }], next: null, prev: null, - }; + }, middleware); copyFields(expectedRes.data[0], res.data[0], ['blockHash']); expect(res).to.be.eql(expectedRes); }); it('gets account pointees pointers', async () => { const res = await middleware.getAccountPointees(presetAccount1Address); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ active: true, blockHash: 'mh_2AVwWGLB7H8McaS1Yr7dfGoepTTVmTXJVFU5TCeDDAxgkyGDAr', @@ -559,14 +561,14 @@ describe('Middleware API', () => { }], next: null, prev: null, - }; + }, middleware); copyFields(expectedRes.data[0], res.data[0], ['blockHash', 'blockTime']); expect(res).to.be.eql(expectedRes); }); it('gets auctions', async () => { const res = await middleware.getNamesAuctions(); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ activationTime: 1721975996873, approximateExpireTime: 1722407457100, @@ -599,7 +601,7 @@ describe('Middleware API', () => { }], next: null, prev: null, - }; + }, middleware); copyFields( expectedRes.data[0], res.data[0], @@ -613,7 +615,7 @@ describe('Middleware API', () => { describe('oracles', () => { it('gets oracles', async () => { const res = await middleware.getOracles(); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ active: true, activeFrom: 10, @@ -659,7 +661,7 @@ describe('Middleware API', () => { }], next: null, prev: null, - }; + }, middleware); copyFields(expectedRes.data[0].register, res.data[0].register, ['blockHash', 'microTime']); copyFields(expectedRes.data[0], res.data[0], ['registerTime', 'approximateExpireTime']); expect(res).to.be.eql(expectedRes); @@ -692,7 +694,7 @@ describe('Middleware API', () => { describe('channels', () => { it('gets channels', async () => { const res = await middleware.getChannels(); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ active: true, amount: 1000000000000000n, @@ -719,7 +721,7 @@ describe('Middleware API', () => { }], next: null, prev: null, - }; + }, middleware); copyFields(expectedRes.data[0], res.data[0], ['lastUpdatedTime']); expect(res).to.be.eql(expectedRes); }); @@ -762,7 +764,7 @@ describe('Middleware API', () => { it('gets delta', async () => { const res = await middleware.getDeltaStats(); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ height: 12, auctionsStarted: 0, @@ -783,14 +785,14 @@ describe('Middleware API', () => { }], next: '/v3/deltastats?cursor=2&limit=10', prev: null, - }; + }, middleware); expectedRes.data.push(...res.data.slice(1)); expect(res).to.be.eql(expectedRes); }); it('gets total', async () => { const res = await middleware.getTotalStats(); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ height: 12, contracts: 2, @@ -810,44 +812,47 @@ describe('Middleware API', () => { }], next: '/v3/totalstats?cursor=2&limit=10', prev: null, - }; + }, middleware); expectedRes.data.push(...res.data.slice(1)); expect(res).to.be.eql(expectedRes); }); it('gets miner', async () => { const res = await middleware.getMinerStats(); - const expectedRes: typeof res = { data: [], next: null, prev: null }; + const expectedRes: typeof res = new MiddlewarePage( + { data: [], next: null, prev: null }, + middleware, + ); expect(res).to.be.eql(expectedRes); }); it('gets blocks', async () => { const res = await middleware.getBlocksStatistics(); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ count: 24, endDate, startDate }], next: null, prev: null, - }; + }, middleware); expect(res).to.be.eql(expectedRes); }); it('gets transactions', async () => { const res = await middleware.getTransactionsStatistics(); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ count: 11, endDate, startDate }], next: null, prev: null, - }; + }, middleware); expect(res).to.be.eql(expectedRes); }); it('gets names', async () => { const res = await middleware.getNamesStatistics(); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: [{ count: 0, endDate, startDate }], next: null, prev: null, - }; + }, middleware); expect(res).to.be.eql(expectedRes); }); }); @@ -866,10 +871,8 @@ describe('Middleware API', () => { expect(res).to.be.eql(expectedRes); }); - interface Activities { data: Activity[]; next: string | null; prev: string | null } - it('gets first page', async () => { - const res = await middleware.requestByPath( + const res = await middleware.requestByPath>( `/v3/accounts/${presetAccount1Address}/activities`, ); const expectedRes: typeof res = await middleware.getAccountActivities(presetAccount1Address); @@ -877,27 +880,60 @@ describe('Middleware API', () => { }); it('gets first page with query parameters', async () => { - const res = await middleware.requestByPath( + const res = await middleware.requestByPath>( `/v3/accounts/${presetAccount1Address}/activities?limit=1`, ); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: (await middleware.getAccountActivities(presetAccount1Address)).data.slice(0, 1), next: `/v3/accounts/${presetAccount1Address}/activities?cursor=3-3-1&limit=1`, prev: null, - }; + }, middleware); expect(res).to.be.eql(expectedRes); }); it('gets second page', async () => { - const res = await middleware.requestByPath( + const res = await middleware.requestByPath>( `/v3/accounts/${presetAccount1Address}/activities?cursor=3-3-1&limit=1`, ); - const expectedRes: typeof res = { + const expectedRes: typeof res = new MiddlewarePage({ data: (await middleware.getAccountActivities(presetAccount1Address)).data.slice(1, 2), next: `/v3/accounts/${presetAccount1Address}/activities?cursor=3-3-0&limit=1`, prev: `/v3/accounts/${presetAccount1Address}/activities?cursor=3-3-1&limit=1&rev=1`, - }; + }, middleware); + expect(res).to.be.eql(expectedRes); + }); + }); + + describe('pagination', () => { + it('nevigates to the next page', async () => { + const first = await middleware.getTransactions({ limit: 1 }); + const res = await first.next(); + const expectedRes: typeof res = new MiddlewarePage({ + data: (await middleware.getTransactions()).data.slice(1, 2), + next: '/v3/transactions?cursor=8&limit=1', + prev: '/v3/transactions?cursor=10&limit=1&rev=1', + }, middleware); expect(res).to.be.eql(expectedRes); }); + + it('nevigates to the previous page', async () => { + const first = await middleware.getTransactions({ limit: 1 }); + const second = await first.next(); + const res = await second.prev(); + expect(res).to.be.eql(first); + expect(res.prevPath).to.be.eql(null); + const expectedRes: typeof res = new MiddlewarePage({ + data: (await middleware.getTransactions()).data.slice(0, 1), + next: '/v3/transactions?cursor=9&limit=1', + prev: null, + }, middleware); + expect(res).to.be.eql(expectedRes); + }); + + it('fails to navigate out of page range', async () => { + const first = await middleware.getTransactions({ limit: 1 }); + await expect(first.prev()) + .to.be.rejectedWith(MiddlewarePageMissed, 'There is no previous page'); + }); }); }); diff --git a/tooling/autorest/postprocessing.mjs b/tooling/autorest/postprocessing.mjs index 25cb0baf3d..9a6e855ac4 100644 --- a/tooling/autorest/postprocessing.mjs +++ b/tooling/autorest/postprocessing.mjs @@ -58,6 +58,32 @@ await Promise.all([ content = content.replaceAll('topics: number[]', 'topics: bigint[]'); } + if (name === 'middleware') { + content = `import { MiddlewarePage } from "../../../utils/MiddlewarePage";\n${content}`; + content = content.replace(/export interface PaginatedResponse {.*?}\n\n/gs, ''); + content = content.replace( + /extends PaginatedResponse,(\s+)(\w+) {}/gs, + 'extends $2,$1PaginatedResponse {}', + ); + const responseRe = /export interface (\w+)\s+extends (\w+),\s+PaginatedResponse {}/s; + while (content.match(responseRe)) { + const [response, responseTypeName, dataTypeName] = content.match(responseRe); + const regExp = new RegExp( + String.raw`export interface ${dataTypeName} {\s+data: (\w+)\[\];\s+}\n\n`, + 's', + ); + const match = content.match(regExp); + if (match == null) throw new Error(`Can't find interface ${dataTypeName}`); + const [, arrayItemTypeName] = match; + content = content.replace(new RegExp(regExp, 'g'), ''); + content = content.replace(response, ''); + content = content.replace(responseTypeName, `MiddlewarePage<${arrayItemTypeName}>`); + } + if (content.includes('PaginatedResponse')) { + throw new Error('Not all PaginatedResponse instances removed'); + } + } + await fs.promises.writeFile(path, content); })(), (async () => { const path = `./src/apis/${name}/models/mappers.ts`;