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

fead: custom fragments support #1082

Draft
wants to merge 1 commit into
base: next
Choose a base branch
from
Draft
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
3 changes: 3 additions & 0 deletions examples/custom-fragments/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Custom Fragments

[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/lens-protocol/lens-sdk/tree/next/examples/custom-fragments)
18 changes: 18 additions & 0 deletions examples/custom-fragments/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
</head>
<body>
<h1>Custom Fragments</h1>
<div id="app">Loading...</div>
<script type="module">
import out from './index.ts';
document.querySelector('#app').innerHTML = Array.isArray(out)
? out.map((x) => `<div style="margin-bottom: 16px;">${x}</div>`).join('')
: out;
</script>
</body>
</html>
42 changes: 42 additions & 0 deletions examples/custom-fragments/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
type Account,
type FragmentOf,
PublicClient,
UsernameFragment,
evmAddress,
graphql,
testnet,
} from '@lens-protocol/client';
import { fetchAccount } from '@lens-protocol/client/actions';

const MyAccountFragment = graphql(
`
fragment Account on Account {
__typename
handle: username {
...Username
}
}
`,
[UsernameFragment],
);

declare module '@lens-protocol/client' {
export interface Account extends FragmentOf<typeof MyAccountFragment> {}
}

const client = PublicClient.create({
environment: testnet,
fragments: {
Account: MyAccountFragment,
},
});

const account: Account | null = await fetchAccount(client, {
address: evmAddress('0x57b62a1571F4F09CDB4C3d93dA542bfe142D9F81'),
}).unwrapOr(null);

export default [
`<h2>${account?.handle?.value}</h2>`,
`<pre>${JSON.stringify(account, null, 2)}</pre>`,
];
19 changes: 19 additions & 0 deletions examples/custom-fragments/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "example-custom-fragments",
"private": true,
"type": "module",
"scripts": {
"dev": "vite"
},
"dependencies": {
"@lens-network/sdk": "canary",
"@lens-protocol/client": "link:../../packages/client",
"@lens-protocol/metadata": "next",
"@lens-protocol/storage-node-client": "next",
"viem": "^2.21.55"
},
"devDependencies": {
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
19 changes: 19 additions & 0 deletions examples/custom-fragments/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["ESNext", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true
},
"include": ["./"]
}
1 change: 1 addition & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"@lens-protocol/types": "workspace:*",
"@urql/core": "^5.0.8",
"@urql/exchange-auth": "^2.2.0",
"graphql": "^16.9.0",
"jwt-decode": "^4.0.0",
"loglevel": "^1.9.2"
},
Expand Down
63 changes: 52 additions & 11 deletions packages/client/src/clients.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { CurrentSessionQuery, HealthQuery, RefreshMutation, Role } from '@lens-protocol/graphql';
import {
CurrentSessionQuery,
HealthQuery,
RefreshMutation,
Role,
UsernameFragment,
graphql,
} from '@lens-protocol/graphql';
import { url, assertErr, assertOk, signatureFrom } from '@lens-protocol/types';
import { HttpResponse, graphql, passthrough } from 'msw';
import * as msw from 'msw';
import { setupServer } from 'msw/node';

import { afterAll, beforeAll, describe, expect, it } from 'vitest';

import { currentSession } from './actions';
import { currentSession, fetchAccount } from './actions';
import { PublicClient } from './clients';
import { GraphQLErrorCode, UnauthenticatedError, UnexpectedError } from './errors';
import {
Expand Down Expand Up @@ -129,18 +136,18 @@ describe(`Given an instance of the ${PublicClient.name}`, () => {

describe('When a request fails with UNAUTHENTICATED extension code', () => {
const server = setupServer(
graphql.query(
msw.graphql.query(
CurrentSessionQuery,
(_) =>
HttpResponse.json({
msw.HttpResponse.json({
errors: [createGraphQLErrorObject(GraphQLErrorCode.UNAUTHENTICATED)],
}),
{
once: true,
},
),
// Pass through all other operations
graphql.operation(() => passthrough()),
msw.graphql.operation(() => msw.passthrough()),
);

beforeAll(() => {
Expand Down Expand Up @@ -173,18 +180,18 @@ describe(`Given an instance of the ${PublicClient.name}`, () => {

describe('When a token refresh fails', () => {
const server = setupServer(
graphql.query(CurrentSessionQuery, (_) =>
HttpResponse.json({
msw.graphql.query(CurrentSessionQuery, (_) =>
msw.HttpResponse.json({
errors: [createGraphQLErrorObject(GraphQLErrorCode.UNAUTHENTICATED)],
}),
),
graphql.mutation(RefreshMutation, (_) =>
HttpResponse.json({
msw.graphql.mutation(RefreshMutation, (_) =>
msw.HttpResponse.json({
errors: [createGraphQLErrorObject(GraphQLErrorCode.BAD_USER_INPUT)],
}),
),
// Pass through all other operations
graphql.operation(() => passthrough()),
msw.graphql.operation(() => msw.passthrough()),
);

beforeAll(() => {
Expand All @@ -211,4 +218,38 @@ describe(`Given an instance of the ${PublicClient.name}`, () => {
});
});
});

describe.only('When some fragments are provided', () => {
it('Then it should replace them in any relevant query', async () => {
const BaseAccountFragment = graphql(
`fragment BaseAccount on Account {
test: address
}`,
);
const AccountFragment = graphql(
`fragment Account on Account {
...BaseAccount
username {
...Username
}
}`,
[BaseAccountFragment, UsernameFragment],
);

const client = createPublicClient({
fragments: [AccountFragment],
});

const result = await fetchAccount(client, { address: account });

assertOk(result);

expect(result.value).toMatchObject({
test: account,
username: {
value: expect.any(String),
},
});
});
});
});
67 changes: 64 additions & 3 deletions packages/client/src/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import type {
SignedAuthChallenge,
StandardData,
} from '@lens-protocol/graphql';
import type { SwitchAccountRequest } from '@lens-protocol/graphql';
import type { Credentials, IStorage } from '@lens-protocol/storage';
import { createCredentialsStorage } from '@lens-protocol/storage';
import {
ResultAsync,
type TxHash,
errAsync,
invariant,
never,
okAsync,
signatureFrom,
} from '@lens-protocol/types';
Expand All @@ -25,10 +27,10 @@ import {
createClient,
fetchExchange,
} from '@urql/core';
import { type AuthConfig, authExchange } from '@urql/exchange-auth';
import { type FragmentDefinitionNode, visit } from 'graphql';
import { type Logger, getLogger } from 'loglevel';

import type { SwitchAccountRequest } from '@lens-protocol/graphql';
import { type AuthConfig, authExchange } from '@urql/exchange-auth';
import { type AuthenticatedUser, authenticatedUser } from './AuthenticatedUser';
import { revokeAuthentication, switchAccount, transactionStatus } from './actions';
import type { ClientConfig } from './config';
Expand Down Expand Up @@ -267,7 +269,66 @@ export class PublicClient<TContext extends Context = Context> extends AbstractCl
document: TypedDocumentNode<StandardData<TValue>, TVariables>,
variables: TVariables,
): ResultAsync<TValue, UnexpectedError> {
return this.resultFrom(this.urql.query(document, variables)).map(takeValue);
// document.definitions.map((def) => {
// //@ts-ignore
// if (def.kind === 'FragmentDefinition') {
// console.log(def.name.value);
// if (def.name.value === 'Account') {
// //@ts-ignore
// console.log(JSON.stringify(def.selectionSet, null, 2));
// }
// }
// });
// console.log(this.context.fragments);
const insertionCandidates = new Map<string, FragmentDefinitionNode>();

const replaced = visit(document, {
FragmentDefinition: (node) => {
if (insertionCandidates.has(node.name.value)) {
insertionCandidates.delete(node.name.value);
}
if (this.context.fragments.has(node.name.value)) {
const [replacement, ...others] =
this.context.fragments.get(node.name.value)?.definitions ?? never();

for (const other of others) {
invariant(other.kind === 'FragmentDefinition', 'Expected a FragmentDefinition');
insertionCandidates.set(other.name.value, other);
}

return replacement;
}
return node;
},
Document: {
leave: (node) => {
return {
...node,
definitions: [...node.definitions, ...insertionCandidates.values()],
};
},
},
});

const usedFragments = new Set<string>();
visit(replaced, {
FragmentSpread(node) {
usedFragments.add(node.name.value);
},
});

console.log(usedFragments);

const cleaned = visit(replaced, {
FragmentDefinition(node) {
if (usedFragments.has(node.name.value)) {
return node;
}
return null;
},
});

return this.resultFrom(this.urql.query(cleaned, variables)).map(takeValue);
}

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/client/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { EnvironmentConfig } from '@lens-protocol/env';
import type { IStorageProvider } from '@lens-protocol/storage';
import type { TypedDocumentNode } from '@urql/core';

/**
* The client configuration.
Expand Down Expand Up @@ -34,4 +35,9 @@ export type ClientConfig = {
* @defaultValue {@link InMemoryStorageProvider}
*/
storage?: IStorageProvider;

/**
* The custom fragments to use.
*/
fragments?: TypedDocumentNode[];
};
19 changes: 19 additions & 0 deletions packages/client/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { EnvironmentConfig } from '@lens-protocol/env';
import { type IStorageProvider, InMemoryStorageProvider } from '@lens-protocol/storage';
import { invariant } from '@lens-protocol/types';
import type { TypedDocumentNode } from '@urql/core';

import type { ClientConfig } from './config';

/**
Expand All @@ -11,6 +14,7 @@ export type Context = {
debug: boolean;
origin?: string;
storage: IStorageProvider;
fragments: Map<string, TypedDocumentNode>;
};

/**
Expand All @@ -23,5 +27,20 @@ export function configureContext(from: ClientConfig): Context {
debug: from.debug ?? false,
origin: from.origin,
storage: from.storage ?? new InMemoryStorageProvider(),
fragments: fragmentsMap(from.fragments),
};
}

function fragmentsMap(fragments: TypedDocumentNode[] = []): Map<string, TypedDocumentNode> {
const map = new Map<string, TypedDocumentNode>();

for (const fragment of fragments) {
invariant(
fragment.definitions[0]?.kind === 'FragmentDefinition',
`ClientConfig: Expected a fragment definition, got ${fragment.definitions[0]?.kind}`,
);
map.set(fragment.definitions[0].name.value, fragment);
}

return map;
}
5 changes: 3 additions & 2 deletions packages/client/src/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { evmAddress } from '@lens-protocol/types';
import { http, type Account, type Transport, type WalletClient, createWalletClient } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';

import { GraphQLErrorCode, PublicClient, staging as apiEnv } from '.';
import { type ClientConfig, GraphQLErrorCode, PublicClient, staging as apiEnv } from '.';

const pk = privateKeyToAccount(import.meta.env.PRIVATE_KEY);
export const account = evmAddress(import.meta.env.TEST_ACCOUNT);
Expand All @@ -20,10 +20,11 @@ export const wallet: WalletClient<Transport, chains.LensNetworkChain, Account> =
);
export const signer = evmAddress(wallet.account.address);

export function createPublicClient() {
export function createPublicClient(config: Partial<ClientConfig> = {}) {
return PublicClient.create({
environment: apiEnv,
origin: 'http://example.com',
...config,
});
}

Expand Down
Loading
Loading