Skip to content

Commit d16cdae

Browse files
committed
feat: introduces @aave/client package
1 parent bd0ee30 commit d16cdae

30 files changed

+1205
-8
lines changed

jest-extended.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type CustomMatchers from 'jest-extended';
2+
import 'vitest';
3+
4+
declare module 'vitest' {
5+
interface Assertion<T = unknown> extends CustomMatchers<T> {}
6+
interface AsymmetricMatchersContaining<T = unknown>
7+
extends CustomMatchers<T> {}
8+
interface ExpectStatic extends CustomMatchers<T> {}
9+
}

packages/client/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# `@aave/client`
2+
3+
The official JavaScript client for the Aave API.
4+
5+
---
6+
7+
This package enables you to interact with the Aave API via a type safe interface that abstracts away some of the GraphQL intricacies.

packages/client/package.json

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"name": "@aave/client",
3+
"version": "0.0.0",
4+
"description": "The official JavaScript client for the Aave API",
5+
"repository": {
6+
"directory": "packages/client",
7+
"type": "git",
8+
"url": "git://github.com/aave/aave-sdk.git"
9+
},
10+
"type": "module",
11+
"main": "dist/index.cjs",
12+
"module": "dist/index.js",
13+
"types": "dist/index.d.ts",
14+
"exports": {
15+
".": {
16+
"import": "./dist/index.js",
17+
"require": "./dist/index.cjs"
18+
}
19+
},
20+
"typesVersions": {
21+
"*": {
22+
"import": [
23+
"./dist/index.d.ts"
24+
],
25+
"require": [
26+
"./dist/index.d.cts"
27+
]
28+
}
29+
},
30+
"files": [
31+
"dist"
32+
],
33+
"sideEffects": false,
34+
"scripts": {
35+
"build": "tsup"
36+
},
37+
"dependencies": {
38+
"@aave/env": "workspace:*",
39+
"@aave/graphql": "workspace:*",
40+
"@aave/types": "workspace:*",
41+
"@urql/core": "^5.2.0",
42+
"graphql": "^16.11.0"
43+
},
44+
"devDependencies": {
45+
"tsup": "^8.5.0",
46+
"typescript": "^5.6.3"
47+
},
48+
"license": "MIT"
49+
}

packages/client/src/batch.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import type { AnyVariables, StandardData } from '@aave/graphql';
2+
import { Deferred, invariant, never, ResultAsync } from '@aave/types';
3+
import type { TypedDocumentNode } from '@urql/core';
4+
import {
5+
type DocumentNode,
6+
type FieldNode,
7+
type FragmentDefinitionNode,
8+
Kind,
9+
type OperationDefinitionNode,
10+
OperationTypeNode,
11+
type VariableDefinitionNode,
12+
visit,
13+
} from 'graphql';
14+
15+
import { UnexpectedError } from './errors';
16+
17+
interface StoredQuery<TValue, TVariables extends AnyVariables> {
18+
alias: string;
19+
document: TypedDocumentNode<StandardData<TValue>, TVariables>;
20+
variables: AnyVariables;
21+
deferred: Deferred<TValue>;
22+
}
23+
24+
export type BatchQueryData = Record<string, unknown>;
25+
26+
export class BatchQueryBuilder {
27+
// biome-ignore lint/suspicious/noExplicitAny: intentional due to the etherogenous nature of the queries
28+
private queries: StoredQuery<any, any>[] = [];
29+
30+
addQuery = <TValue, TVariables extends AnyVariables>(
31+
document: TypedDocumentNode<StandardData<TValue>, TVariables>,
32+
variables: TVariables,
33+
): ResultAsync<TValue, UnexpectedError> => {
34+
invariant(
35+
this.queries.length < 10,
36+
'Batch queries supports a maximum of 10 queries',
37+
);
38+
39+
const alias = `value_${this.queries.length}`;
40+
const deferred = new Deferred<TValue>();
41+
42+
this.queries.push({ alias, document, variables, deferred });
43+
44+
return ResultAsync.fromPromise(deferred.promise, (err) => {
45+
if (UnexpectedError.is(err)) {
46+
return err;
47+
}
48+
return UnexpectedError.from(err);
49+
});
50+
};
51+
52+
build<TVariables extends AnyVariables>(): [
53+
TypedDocumentNode<BatchQueryData, TVariables>,
54+
TVariables,
55+
] {
56+
const allFragments: Map<string, FragmentDefinitionNode> = new Map();
57+
const selections: FieldNode[] = [];
58+
const variableDefinitions: VariableDefinitionNode[] = [];
59+
const mergedVariables: AnyVariables = {};
60+
61+
let varId = 0;
62+
for (const { alias, document, variables } of this.queries) {
63+
const [operation, fragments] = extractQueryParts(document);
64+
65+
for (const fragment of fragments) {
66+
const name = fragment.name.value;
67+
if (!allFragments.has(name)) {
68+
allFragments.set(name, fragment);
69+
}
70+
}
71+
72+
const varMapping = new Map<string, string>();
73+
const localDefs =
74+
operation.variableDefinitions?.map((v): VariableDefinitionNode => {
75+
const newVarName = `${v.variable.name.value}_${varId++}`;
76+
varMapping.set(v.variable.name.value, newVarName);
77+
78+
mergedVariables[newVarName] =
79+
variables[v.variable.name.value] ?? never();
80+
81+
return {
82+
...v,
83+
variable: {
84+
...v.variable,
85+
name: { kind: Kind.NAME, value: newVarName },
86+
},
87+
};
88+
}) ?? [];
89+
90+
variableDefinitions.push(...localDefs);
91+
92+
const rewritten = visit(operation.selectionSet, {
93+
Variable(node) {
94+
const renamed = varMapping.get(node.name.value);
95+
if (!renamed) return node;
96+
return {
97+
...node,
98+
name: { kind: Kind.NAME, value: renamed },
99+
};
100+
},
101+
Field(node) {
102+
return {
103+
...node,
104+
alias: { kind: Kind.NAME, value: alias },
105+
};
106+
},
107+
});
108+
109+
selections.push(...(rewritten.selections as FieldNode[]));
110+
}
111+
112+
const mergedOperation: OperationDefinitionNode = {
113+
kind: Kind.OPERATION_DEFINITION,
114+
operation: OperationTypeNode.QUERY,
115+
variableDefinitions,
116+
selectionSet: {
117+
kind: Kind.SELECTION_SET,
118+
selections,
119+
},
120+
};
121+
122+
const mergedDocument: DocumentNode = {
123+
kind: Kind.DOCUMENT,
124+
definitions: [mergedOperation, ...allFragments.values()],
125+
};
126+
return [mergedDocument, mergedVariables as TVariables];
127+
}
128+
129+
resolve(data: BatchQueryData) {
130+
for (const { alias, deferred } of this.queries) {
131+
if (Object.hasOwn(data, alias) && data[alias] !== undefined) {
132+
deferred.resolve(data[alias]);
133+
} else {
134+
deferred.reject(
135+
UnexpectedError.from(
136+
`Missing response data for query alias "${alias}". Please report this issue to the Avara team.`,
137+
),
138+
);
139+
}
140+
}
141+
}
142+
}
143+
144+
function extractQueryParts(
145+
document: TypedDocumentNode<StandardData<unknown>>,
146+
): [OperationDefinitionNode, FragmentDefinitionNode[]] {
147+
let operation: OperationDefinitionNode | undefined;
148+
const fragments: FragmentDefinitionNode[] = [];
149+
for (const definition of document.definitions) {
150+
switch (definition.kind) {
151+
case Kind.OPERATION_DEFINITION:
152+
invariant(
153+
definition.operation === OperationTypeNode.QUERY,
154+
'Only query operations are supported',
155+
);
156+
invariant(
157+
operation === undefined,
158+
'Only one operation definition is supported',
159+
);
160+
161+
operation = definition;
162+
break;
163+
164+
case Kind.FRAGMENT_DEFINITION:
165+
fragments.push(definition);
166+
break;
167+
168+
default:
169+
never(`Unexpected definition kind: ${definition.kind}`);
170+
}
171+
}
172+
173+
invariant(operation, 'No operation definition found in the document');
174+
175+
return [operation, fragments];
176+
}

0 commit comments

Comments
 (0)