Skip to content

Commit

Permalink
[CM] Setup client side content client (#150171)
Browse files Browse the repository at this point in the history
## Summary

Partially addresses #149216,
#149939
Closes #149217


Setup basic client-side content client (client, cache, react content and
hooks) based on the latest API from @sebelga's [server-side and rpc
POC](#148791). We assume that the
POC isn't far from the initial version that we are going to merge to
main

Initial content client implementation scope: 
- We start from using tanstack/query for client side caching, content
queuing and mutating . Also use it as a basis for the API. We start from
their defaults and will adjust defaults as we go. Basically our content
client would be a helpful wrapper around tanstack/query (at least
initially)
- We start from exposing minimal query lib API surface, but we will
gradually expose more as needed
- For content retrieval we support promise based, RXJS observable based
and react hook based methods
- For the RPC/api I copied over the latest from [server-side and rpc
POC](#148791) assuming it won't
change much.
- no optimistic update or automatic invalidation after updates yet (this
will be later)
- just get and create for now
  • Loading branch information
Dosant authored Feb 7, 2023
1 parent 3e963b6 commit 6d96f51
Show file tree
Hide file tree
Showing 17 changed files with 491 additions and 6 deletions.
2 changes: 2 additions & 0 deletions src/plugins/content_management/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@
*/

export { PLUGIN_ID, API_ENDPOINT } from './constants';
export type { ProcedureSchemas, ProcedureName, GetIn, CreateIn } from './rpc';
export { procedureNames, schemas as rpcSchemas } from './rpc';
72 changes: 72 additions & 0 deletions src/plugins/content_management/common/rpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { schema, Type } from '@kbn/config-schema';

export interface ProcedureSchemas {
in?: Type<any> | false;
out?: Type<any> | false;
}

export const procedureNames = ['get', 'create'] as const;

export type ProcedureName = typeof procedureNames[number];

// ---------------------------------
// API
// ---------------------------------

// ------- GET --------
const getSchemas: ProcedureSchemas = {
in: schema.object(
{
contentType: schema.string(),
id: schema.string(),
options: schema.maybe(schema.object({}, { unknowns: 'allow' })),
},
{ unknowns: 'forbid' }
),
// --> "out" will be specified by each storage layer
out: schema.maybe(schema.object({}, { unknowns: 'allow' })),
};

export interface GetIn<Options extends object | undefined = undefined> {
id: string;
contentType: string;
options?: Options;
}

// -- Create content
const createSchemas: ProcedureSchemas = {
in: schema.object(
{
contentType: schema.string(),
data: schema.object({}, { unknowns: 'allow' }),
options: schema.maybe(schema.object({}, { unknowns: 'allow' })),
},
{ unknowns: 'forbid' }
),
// Here we could enforce that an "id" field is returned
out: schema.maybe(schema.object({}, { unknowns: 'allow' })),
};

export interface CreateIn<
T extends string = string,
Data extends object = Record<string, unknown>,
Options extends object = any
> {
contentType: T;
data: Data;
options?: Options;
}

export const schemas: {
[key in ProcedureName]: ProcedureSchemas;
} = {
get: getSchemas,
create: createSchemas,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { lastValueFrom } from 'rxjs';
import { takeWhile, toArray } from 'rxjs/operators';
import type { RpcClient } from '../rpc_client';
import { createRpcClientMock } from '../rpc_client/rpc_client.mock';
import { ContentClient } from './content_client';
import type { GetIn, CreateIn } from '../../common';

let contentClient: ContentClient;
let rpcClient: jest.Mocked<RpcClient>;
beforeEach(() => {
rpcClient = createRpcClientMock();
contentClient = new ContentClient(rpcClient);
});

describe('#get', () => {
it('calls rpcClient.get with input and returns output', async () => {
const input: GetIn = { id: 'test', contentType: 'testType' };
const output = { test: 'test' };
rpcClient.get.mockResolvedValueOnce(output);
expect(await contentClient.get(input)).toEqual(output);
expect(rpcClient.get).toBeCalledWith(input);
});

it('calls rpcClient.get$ with input and returns output', async () => {
const input: GetIn = { id: 'test', contentType: 'testType' };
const output = { test: 'test' };
rpcClient.get.mockResolvedValueOnce(output);
const get$ = contentClient.get$(input).pipe(
takeWhile((result) => {
return result.data == null;
}, true),
toArray()
);

const [loadingState, loadedState] = await lastValueFrom(get$);

expect(loadingState.isLoading).toBe(true);
expect(loadingState.data).toBeUndefined();

expect(loadedState.isLoading).toBe(false);
expect(loadedState.data).toEqual(output);
});
});

describe('#create', () => {
it('calls rpcClient.create with input and returns output', async () => {
const input: CreateIn = { contentType: 'testType', data: { foo: 'bar' } };
const output = { test: 'test' };
rpcClient.create.mockImplementation(() => Promise.resolve(output));

expect(await contentClient.create(input)).toEqual(output);
expect(rpcClient.create).toBeCalledWith(input);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { QueryClient } from '@tanstack/react-query';
import { createQueryObservable } from './query_observable';
import type { RpcClient } from '../rpc_client';
import type { CreateIn, GetIn } from '../../common';

const queryKeyBuilder = {
all: (type: string) => [type] as const,
item: (type: string, id: string) => {
return [...queryKeyBuilder.all(type), id] as const;
},
};

const createQueryOptionBuilder = ({ rpcClient }: { rpcClient: RpcClient }) => {
return {
get: <I extends GetIn = GetIn, O = unknown>(input: I) => {
return {
queryKey: queryKeyBuilder.item(input.contentType, input.id),
queryFn: () => rpcClient.get<I, O>(input),
};
},
};
};

export class ContentClient {
readonly queryClient: QueryClient;
readonly queryOptionBuilder: ReturnType<typeof createQueryOptionBuilder>;

constructor(private readonly rpcClient: RpcClient) {
this.queryClient = new QueryClient();
this.queryOptionBuilder = createQueryOptionBuilder({
rpcClient: this.rpcClient,
});
}

get<I extends GetIn = GetIn, O = unknown>(input: I): Promise<O> {
return this.queryClient.fetchQuery(this.queryOptionBuilder.get(input));
}

get$<I extends GetIn = GetIn, O = unknown>(input: I) {
return createQueryObservable(this.queryClient, this.queryOptionBuilder.get<I, O>(input));
}

create<I extends CreateIn, O = any>(input: I): Promise<O> {
return this.rpcClient.create(input);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import React from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import type { ContentClient } from './content_client';

const ContentClientContext = React.createContext<ContentClient>(null as unknown as ContentClient);

export const useContentClient = (): ContentClient => {
const contentClient = React.useContext(ContentClientContext);
if (!contentClient) throw new Error('contentClient not found');
return contentClient;
};

export const ContentClientProvider: React.FC<{ contentClient: ContentClient }> = ({
contentClient,
children,
}) => {
return (
<ContentClientContext.Provider value={contentClient}>
<QueryClientProvider client={contentClient.queryClient}>{children}</QueryClientProvider>
</ContentClientContext.Provider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { ContentClientProvider } from './content_client_context';
import { ContentClient } from './content_client';
import { RpcClient } from '../rpc_client';
import { createRpcClientMock } from '../rpc_client/rpc_client.mock';
import { useCreateContentMutation } from './content_client_mutation_hooks';
import type { CreateIn } from '../../common';

let contentClient: ContentClient;
let rpcClient: jest.Mocked<RpcClient>;
beforeEach(() => {
rpcClient = createRpcClientMock();
contentClient = new ContentClient(rpcClient);
});

const Wrapper: React.FC = ({ children }) => (
<ContentClientProvider contentClient={contentClient}>{children}</ContentClientProvider>
);

describe('useCreateContentMutation', () => {
test('should call rpcClient.create with input and resolve with output', async () => {
const input: CreateIn = { contentType: 'testType', data: { foo: 'bar' } };
const output = { test: 'test' };
rpcClient.create.mockImplementation(() => Promise.resolve(output));
const { result, waitFor } = renderHook(() => useCreateContentMutation(), { wrapper: Wrapper });
result.current.mutate(input);

await waitFor(() => result.current.isSuccess);

expect(result.current.data).toEqual(output);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { useMutation } from '@tanstack/react-query';
import { useContentClient } from './content_client_context';
import type { CreateIn } from '../../common';

export const useCreateContentMutation = <I extends CreateIn = CreateIn, O = unknown>() => {
const contentClient = useContentClient();
return useMutation({
mutationFn: (input: I) => {
return contentClient.create(input);
},
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { ContentClientProvider } from './content_client_context';
import { ContentClient } from './content_client';
import { RpcClient } from '../rpc_client';
import { createRpcClientMock } from '../rpc_client/rpc_client.mock';
import { useGetContentQuery } from './content_client_query_hooks';
import type { GetIn } from '../../common';

let contentClient: ContentClient;
let rpcClient: jest.Mocked<RpcClient>;
beforeEach(() => {
rpcClient = createRpcClientMock();
contentClient = new ContentClient(rpcClient);
});

const Wrapper: React.FC = ({ children }) => (
<ContentClientProvider contentClient={contentClient}>{children}</ContentClientProvider>
);

describe('useGetContentQuery', () => {
test('should call rpcClient.get with input and resolve with output', async () => {
const input: GetIn = { id: 'test', contentType: 'testType' };
const output = { test: 'test' };
rpcClient.get.mockImplementation(() => Promise.resolve(output));
const { result, waitFor } = renderHook(() => useGetContentQuery(input), { wrapper: Wrapper });
await waitFor(() => result.current.isSuccess);
expect(result.current.data).toEqual(output);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { useQuery, QueryObserverOptions } from '@tanstack/react-query';
import { useContentClient } from './content_client_context';
import type { GetIn } from '../../common';

/**
* Exposed `useQuery` options
*/
export type QueryOptions = Pick<QueryObserverOptions, 'enabled'>;

/**
*
* @param input - get content identifier like "id" and "contentType"
* @param queryOptions -
*/
export const useGetContentQuery = <I extends GetIn = GetIn, O = unknown>(
input: I,
queryOptions?: QueryOptions
) => {
const contentClient = useContentClient();
return useQuery({
...contentClient.queryOptionBuilder.get<I, O>(input),
...queryOptions,
});
};
12 changes: 12 additions & 0 deletions src/plugins/content_management/public/content_client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

export { ContentClient } from './content_client';
export { ContentClientProvider, useContentClient } from './content_client_context';
export { useGetContentQuery } from './content_client_query_hooks';
export { useCreateContentMutation } from './content_client_mutation_hooks';
Loading

0 comments on commit 6d96f51

Please sign in to comment.