@data-client/rest
Version:
Quickly define typed REST resources and endpoints
312 lines (284 loc) • 9.28 kB
text/typescript
import { schema, Entity, Schema } from '@data-client/endpoint';
import { useController } from '@data-client/react';
import { useSuspense } from '@data-client/react';
import { CacheProvider } from '@data-client/react';
import nock from 'nock';
import { makeRenderDataClient } from '../../../test';
import { ResourcePath } from '../pathTypes';
import resource from '../resource';
import RestEndpoint from '../RestEndpoint';
import {
payload,
createPayload,
users,
nested,
moreNested,
paginatedFirstPage,
paginatedSecondPage,
} from '../test-fixtures';
const { Collection } = schema;
export class User extends Entity {
readonly id: number | undefined = undefined;
readonly username: string = '';
readonly email: string = '';
readonly isAdmin: boolean = false;
}
export const UserResource = resource({
path: 'http\\://test.com/user/:id',
schema: User,
});
export class PaginatedArticle extends Entity {
readonly id: number | undefined = undefined;
readonly title: string = '';
readonly content: string = '';
readonly author: number | null = null;
readonly tags: string[] = [];
static schema = {
author: User,
};
}
function createPaginatableResource<U extends ResourcePath, S extends Schema>({
path,
schema,
Endpoint = RestEndpoint,
}: {
readonly path: U;
readonly schema: S;
readonly Endpoint?: typeof RestEndpoint;
}) {
const baseResource = resource({ path, schema, Endpoint });
const getList = baseResource.getList.extend({
path: 'http\\://test.com/article-paginated',
schema: {
nextPage: '',
data: { results: new Collection([PaginatedArticle]) },
},
});
const getNextPage = getList.paginated((v: { cursor: string | number }) => []);
return {
...baseResource,
getList,
getNextPage,
};
}
const PaginatedArticleResource = createPaginatableResource({
path: 'http\\://test.com/article-paginated/:id',
schema: PaginatedArticle,
});
export class UrlArticle extends PaginatedArticle {
readonly url: string = 'happy.com';
}
describe('resource()', () => {
const renderDataClient: ReturnType<typeof makeRenderDataClient> =
makeRenderDataClient(CacheProvider);
let mynock: nock.Scope;
beforeEach(() => {
nock(/.*/)
.persist()
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
})
.options(/.*/)
.reply(200)
.get(`/article-cooler/${payload.id}`)
.reply(200, payload)
.delete(`/article-cooler/${payload.id}`)
.reply(204, '')
.delete(`/article/${payload.id}`)
.reply(200, {})
.get(`/article-cooler/0`)
.reply(403, {})
.get(`/article-cooler/666`)
.reply(200, '')
.get(`/article-cooler`)
.reply(200, nested)
.post(`/article-cooler`)
.reply(200, createPayload)
.get(`/user`)
.reply(200, users);
mynock = nock(/.*/).defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
});
});
afterEach(() => {
nock.cleanAll();
});
it('should handle simple urls', () => {
expect(UserResource.get.url({ id: '5' })).toBe('http://test.com/user/5');
expect(UserResource.get.url({ id: '100' })).toBe(
'http://test.com/user/100',
);
/*expect(UserResource.getList.url({ bob: '100' })).toBe(
'http://test.com/user?bob=100',
);
expect(UserResource.create.url({ bob: '100' })).toBe(
'http://test.com/user',
);*/
expect(
UserResource.update.url({ id: '100' }, { id: 100, username: 'bob' }),
).toBe('http://test.com/user/100');
// @ts-expect-error
() => UserResource.get.url({ sdf: '5' });
});
it('should handle multiarg urls', () => {
const MyUserResource = resource({
path: 'http\\://test.com/groups/:group/users/:id',
schema: User,
});
expect(MyUserResource.get.url({ group: 'big', id: '5' })).toBe(
'http://test.com/groups/big/users/5',
);
expect(MyUserResource.get.url({ group: 'big', id: '100' })).toBe(
'http://test.com/groups/big/users/100',
);
/*expect(MyUserResource.getList.url({ group: 'big', bob: '100' })).toBe(
'http://test.com/groups/big/users?bob=100',
);*/
expect(
MyUserResource.create.url({ group: 'big' }, { username: '100' }),
).toBe('http://test.com/groups/big/users');
expect(
MyUserResource.update.url(
{ group: 'big', id: '100' },
{ id: 100, username: 'bob' },
),
).toBe('http://test.com/groups/big/users/100');
// missing required
expect(() =>
// @ts-expect-error
MyUserResource.get.url({ id: '5' }),
).toThrow();
// extra fields
() =>
MyUserResource.get.url({
group: 'mygroup',
id: '5',
// @ts-expect-error
notexisting: 'hi',
});
// @ts-expect-error
() => useSuspense(MyUserResource.get, { id: '5' });
// @ts-expect-error
() => useSuspense(MyUserResource.get);
() => useSuspense(MyUserResource.get, { group: 'yay', id: '5' });
});
it('should automatically name methods', () => {
expect(PaginatedArticleResource.get.name).toBe('PaginatedArticle.get');
expect(PaginatedArticleResource.create.name).toBe(
'PaginatedArticle.create',
);
expect(PaginatedArticleResource.getList.name).toBe(
'PaginatedArticle.getList',
);
expect(PaginatedArticleResource.delete.name).toBe(
'PaginatedArticle.delete',
);
});
it('should update on get for a paginated resource', async () => {
mynock.get(`/article-paginated`).reply(200, paginatedFirstPage);
mynock.get(`/article-paginated?cursor=2`).reply(200, paginatedSecondPage);
const { result, waitForNextUpdate } = renderDataClient(() => {
const { fetch } = useController();
const {
data: { results: articles },
nextPage,
} = useSuspense(PaginatedArticleResource.getList);
return { articles, nextPage, fetch };
});
await waitForNextUpdate();
() =>
// @ts-expect-error
result.current.fetch(PaginatedArticleResource.getNextPage);
await result.current.fetch(PaginatedArticleResource.getNextPage, {
cursor: 2,
});
expect(result.current.articles.map(({ id }) => id)).toEqual([5, 3, 7, 8]);
});
it('should deduplicate results', async () => {
mynock.get(`/article-paginated`).reply(200, paginatedFirstPage);
mynock.get(`/article-paginated?cursor=2`).reply(200, {
...paginatedSecondPage,
results: [nested[nested.length - 1], ...moreNested],
});
const { result, waitForNextUpdate } = renderDataClient(() => {
const { fetch } = useController();
const {
data: { results: articles },
nextPage,
} = useSuspense(PaginatedArticleResource.getList);
return { articles, nextPage, fetch };
});
await waitForNextUpdate();
await result.current.fetch(PaginatedArticleResource.getNextPage, {
cursor: 2,
});
expect(result.current.articles.map(({ id }) => id)).toEqual([5, 3, 7, 8]);
});
it('should not deep-merge deeply defined entities', async () => {
interface Complex {
firstvalue: number;
secondthing: {
arg?: number;
other?: string;
};
}
class ComplexEntity extends Entity {
readonly id: string = '';
readonly complexThing?: Complex = undefined;
readonly extra: string = '';
pk() {
return this.id;
}
}
const ComplexResource = resource({
path: '/complex-thing/:id',
schema: ComplexEntity,
});
const firstResponse = {
id: '5',
complexThing: {
firstvalue: 233,
secondthing: { arg: 88 },
},
extra: 'hi',
};
mynock.get(`/complex-thing/5`).reply(200, firstResponse);
const { result, waitForNextUpdate } = renderDataClient(() => {
const { fetch } = useController();
const article = useSuspense(ComplexResource.get, { id: '5' });
return { article, fetch };
});
await waitForNextUpdate();
expect(result.current.article).toEqual(firstResponse);
const secondResponse = {
id: '5',
complexThing: {
firstvalue: 5,
secondthing: { other: 'hi' },
},
};
mynock.get(`/complex-thing/5`).reply(200, secondResponse);
await result.current.fetch(ComplexResource.get, {
id: '5',
});
expect(result.current.article).toEqual({ ...secondResponse, extra: 'hi' });
});
it('delete() should fallback to params when response is empty object', async () => {
mynock.delete(`/article-paginated/500`).reply(200, {});
const res = await PaginatedArticleResource.delete({ id: 500 });
expect(res).toEqual({ id: 500 });
});
it('delete() should fallback to params when response is undefined', async () => {
mynock.delete(`/article-paginated/500`).reply(204, undefined);
const res = await PaginatedArticleResource.delete({ id: 500 });
expect(res).toEqual({ id: 500 });
});
it('should spread `url` member', () => {
const entity = UrlArticle.fromJS({ url: 'five' });
const spread = { ...entity };
expect(spread.url).toBe('five');
expect(Object.hasOwn(entity, 'url')).toBeTruthy();
});
});