@data-client/rest
Version:
Quickly define typed REST resources and endpoints
443 lines (399 loc) • 14.2 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 NetworkError from '../NetworkError';
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 omit optional path params when undefined', () => {
const ep = new RestEndpoint({ path: '/users{/:id}' });
expect(ep.url({ id: undefined })).toBe('/users');
expect(ep.url({ id: '5' })).toBe('/users/5');
expect(ep.url({})).toBe('/users');
const ep2 = new RestEndpoint({ path: '/users{/:id}{/:group}' });
expect(ep2.url({ id: undefined, group: undefined })).toBe('/users');
expect(ep2.url({ id: '5', group: undefined })).toBe('/users/5');
expect(ep2.url({ id: undefined })).toBe('/users');
});
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 shorten wildcard (*) paths for getList', () => {
const FileResource = resource({
path: '/repos/:owner/*path',
schema: User,
});
// get uses the full path with wildcard
expect(
FileResource.get.url({ owner: 'john', path: ['src', 'index.ts'] }),
).toBe('/repos/john/src/index.ts');
// getList strips the last *wildcard token
expect(FileResource.getList.url({ owner: 'john' })).toBe('/repos/john');
// create (push) also uses shortened path
expect(FileResource.create.url({ owner: 'john' }, {} as any)).toBe(
'/repos/john',
);
// type checks: get requires both owner and path (array)
// @ts-expect-error - missing path
() => FileResource.get.url({ owner: 'john' });
// @ts-expect-error - path must be array
() => FileResource.get.url({ owner: 'john', path: 'src/index.ts' });
// getList only requires owner (extra props become search params)
() => FileResource.getList({ owner: 'john' });
// @ts-expect-error - missing required owner param
() => FileResource.getList({});
});
it('should shorten path when wildcard comes after multiple params', () => {
const DeepResource = resource({
path: '/api/:version/files/*path',
schema: User,
});
expect(
DeepResource.get.url({ version: 'v2', path: ['docs', 'readme.md'] }),
).toBe('/api/v2/files/docs/readme.md');
expect(DeepResource.getList.url({ version: 'v2' })).toBe('/api/v2/files');
// @ts-expect-error - missing version
() => DeepResource.getList({});
() => DeepResource.getList({ version: 'v2' });
});
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('getList.move should have similar properties to update', () => {
expect(UserResource.getList.move.method).toBe('PATCH');
// move uses the full entity path (same as update), not the shortened list path
expect(UserResource.getList.move.url({ id: '5' }, {})).toBe(
'http://test.com/user/5',
);
expect(UserResource.update.url({ id: '5' }, {})).toBe(
'http://test.com/user/5',
);
// schema is the Collection.move variant (not the raw Entity like update)
expect(UserResource.getList.move.schema).toBeDefined();
expect(UserResource.getList.move.schema).not.toBe(
UserResource.update.schema,
);
// inherits getOptimisticResponse from getList (which has optimistic via extraMutateOptions)
expect(typeof UserResource.getList.move.getOptimisticResponse).toBe(
typeof UserResource.update.getOptimisticResponse,
);
// name distinguishes it from update
expect(UserResource.getList.move.name).toBe('User.getList.partialUpdate');
// searchParams are removed (move targets a specific entity, not a filtered list)
expect(UserResource.getList.move.searchParams).toBeUndefined();
});
it('getList.move should handle multiarg urls', () => {
const MyUserResource = resource({
path: 'http\\://test.com/groups/:group/users/:id',
schema: User,
});
expect(MyUserResource.getList.move.url({ group: 'big', id: '5' }, {})).toBe(
'http://test.com/groups/big/users/5',
);
// same as update path
expect(MyUserResource.update.url({ group: 'big', id: '5' }, {})).toBe(
'http://test.com/groups/big/users/5',
);
});
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();
});
});
describe('NetworkError', () => {
it('toJSON() should serialize error with status, message, and url', () => {
const mockResponse = {
status: 404,
statusText: 'Not Found',
url: 'http://test.com/api/missing',
} as Response;
const error = new NetworkError(mockResponse);
const json = error.toJSON();
expect(json).toEqual({
name: 'NetworkError',
status: 404,
message: 'http://test.com/api/missing: Not Found',
url: 'http://test.com/api/missing',
});
});
it('toJSON() output should be JSON.stringify-able', () => {
const mockResponse = {
status: 500,
statusText: 'Internal Server Error',
url: 'http://test.com/api/broken',
} as Response;
const error = new NetworkError(mockResponse);
const serialized = JSON.stringify(error);
const parsed = JSON.parse(serialized);
expect(parsed.name).toBe('NetworkError');
expect(parsed.status).toBe(500);
expect(parsed.url).toBe('http://test.com/api/broken');
});
});