import { Entity, schema, Collection, Values } from '@data-client/endpoint';
import { useController, useCache } from '@data-client/react';
import { useSuspense } from '@data-client/react';
import { CacheProvider } from '@data-client/react';
import { CoolerArticle, CoolerArticleResource } from '__tests__/new';
import nock from 'nock';
import { makeRenderDataClient, act } from '../../../test';
import RestEndpoint, {
Defaults,
RestEndpointConstructorOptions,
RestGenerics,
} from '../RestEndpoint';
import {
payload,
createPayload,
users,
nested,
moreNested,
paginatedFirstPage,
paginatedSecondPage,
} from '../test-fixtures';
export class User extends Entity {
readonly id: number | undefined = undefined;
readonly username: string = '';
readonly email: string = '';
readonly isAdmin: boolean = false;
}
const getUser = new RestEndpoint({
path: 'http\\://test.com/user/:id',
name: 'User.get',
schema: User,
method: 'GET',
searchParams: {} as { extra?: string },
});
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,
};
}
const getArticleList = new RestEndpoint({
urlPrefix: 'http://test.com',
path: '/article-paginated',
schema: {
nextPage: '',
data: { results: new Collection([PaginatedArticle]) },
},
method: 'GET',
});
const getNextPage = getArticleList.paginated(
(v: { cursor: string | number }) => [],
);
const getArticleList2 = new RestEndpoint({
urlPrefix: 'http://test.com/article-paginated/',
path: ':group',
name: 'get',
schema: {
nextPage: '',
data: { results: new Collection([PaginatedArticle]) },
},
method: 'GET',
});
const getNextPage2 = getArticleList2.paginated(
({
cursor,
...rest
}: {
cursor: string | number;
group: string | number;
}) => [rest],
);
const getArticleList3 = new RestEndpoint({
urlPrefix: 'http://test.com',
path: '/article-paginated',
schema: {
nextPage: '',
data: { results: new Collection([PaginatedArticle]) },
},
method: 'GET',
searchParams: {} as { group: string | number },
paginationField: 'cursor',
}).extend({
dataExpiryLength: 10000,
});
const getNextPage3 = getArticleList3.getPage;
// type tests
() => {
const base = new RestEndpoint({
urlPrefix: 'http://test.com',
path: '/article-paginated',
schema: {
nextPage: '',
data: { results: new Collection([PaginatedArticle]) },
},
method: 'GET',
});
// @ts-expect-error
() => base.getPage();
() =>
// @ts-expect-error
base
.extend({
path: '',
dataExpiryLength: 10000,
})
.getPage();
const a = new RestEndpoint({
urlPrefix: 'http://test.com',
path: '/article-paginated',
schema: {
nextPage: '',
data: { results: new Collection([PaginatedArticle]) },
},
method: 'GET',
searchParams: {} as { group: string | number },
}).extend({
path: ':blob',
searchParams: {} as { isAdmin?: boolean },
method: 'GET',
paginationField: 'cursor',
});
a.getPage({ cursor: 'hi', blob: 'ho' });
// @ts-expect-error
a.getPage({ blob: 'ho' });
// Compare flat vs nested Collection schema type inference
const flat = new RestEndpoint({
urlPrefix: 'http://test.com',
path: '/articles/:id',
schema: new Collection([PaginatedArticle]),
method: 'GET',
});
// flat Collection schema should be typed
// @ts-expect-error - schema should not be any
flat.move.schema satisfies number;
// @ts-expect-error - push schema should not be any
flat.push.schema satisfies number;
// body should be derived from collection's entity schema, not any
// @ts-expect-error
flat.push.body satisfies number;
// @ts-expect-error
flat.move.body satisfies number;
// @ts-expect-error
flat.remove.body satisfies number;
// nested Collection schema should also be typed
const nested = new RestEndpoint({
urlPrefix: 'http://test.com',
path: '/articles/:id',
schema: {
nextPage: '',
data: { results: new Collection([PaginatedArticle]) },
},
method: 'GET',
searchParams: {} as { group: string },
});
// @ts-expect-error - nested move schema should not be any
nested.move.schema satisfies number;
// @ts-expect-error - nested push schema should not be any
nested.push.schema satisfies number;
// @ts-expect-error
nested.push.body satisfies number;
// @ts-expect-error
nested.move.body satisfies number;
// @ts-expect-error
nested.remove.body satisfies number;
// With explicit body: PATCH extenders should have Partial body
type Body = { title: string; content: string };
const withBody = new RestEndpoint({
urlPrefix: 'http://test.com',
path: '/articles/:id',
schema: new Collection([PaginatedArticle]),
method: 'GET',
body: {} as Body,
});
// POST extenders keep full body type
// @ts-expect-error - push body should not be any
withBody.push.body satisfies number;
withBody.push.body satisfies Body | Body[] | FormData | undefined;
// @ts-expect-error - unshift body should not be any
withBody.unshift.body satisfies number;
// PATCH extenders get Partial body (like partialUpdate)
// @ts-expect-error - move body should not be any
withBody.move.body satisfies number;
withBody.move.body satisfies Partial
| FormData | undefined;
// @ts-expect-error - remove body should not be any
withBody.remove.body satisfies number;
withBody.remove.body satisfies
| Partial
| Partial[]
| FormData
| undefined;
// push body requires full Body (not partial)
() => withBody.push({ id: 'a' }, { title: 'hi', content: 'there' });
// @ts-expect-error - push rejects partial body
() => withBody.push({ id: 'a' }, { title: 'hi' });
// move body accepts partial Body (PATCH semantics)
() => withBody.move({ id: 'a' }, { title: 'hi' });
() => withBody.move({ id: 'a' }, { content: 'there' });
// @ts-expect-error - move rejects invalid fields
() => withBody.move({ id: 'a' }, { invalid: 'field' });
// Values Collection: assign body should accept Record
const valuesEndpoint = new RestEndpoint({
urlPrefix: 'http://test.com',
path: '/articles',
schema: new Collection(new Values(PaginatedArticle)),
method: 'GET',
});
// @ts-expect-error - assign body should not be any
valuesEndpoint.assign.body satisfies number;
valuesEndpoint.assign.body satisfies
| Record>
| FormData
| undefined;
() => valuesEndpoint.assign({ myKey: { id: 5, title: 'hi', content: 'ho' } });
};
describe('RestEndpoint', () => {
const renderDataClient: ReturnType =
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('testKey should match keys', () => {
expect(getArticleList.testKey(getArticleList.key())).toBeTruthy();
expect(
getUser.testKey(getUser.key({ id: '100', extra: '345' })),
).toBeTruthy();
expect(getUser.testKey(getUser.key({ id: 'xxx?*' }))).toBeTruthy();
});
it('should assign members', () => {
expect(getUser.path).toBe('http\\://test.com/user/:id');
expect(getUser.sideEffect).toBe(undefined);
expect(getUser.method).toBe('GET');
// @ts-expect-error
() => getUser.notassigned;
// @ts-expect-error
const a: true = getUser.sideEffect;
// @ts-expect-error
const b: 'POST' = getUser.method;
// @ts-expect-error
((m: 'POST') => {})(getUser.method);
const updateUser = new RestEndpoint({
path: 'http\\://test.com/user/:id',
name: 'update',
schema: User,
method: 'POST',
});
expect(updateUser.sideEffect).toBe(true);
// @ts-expect-error
const y: undefined = updateUser.sideEffect;
});
it('only optional path means the arg is not required', () => {
const ep = new RestEndpoint({ path: '/users{/:id}{/:group}' });
const epbody = new RestEndpoint({
path: '/users{/:id}{/:group}',
body: { title: '' },
method: 'POST',
});
() => ep();
() => ep({ id: 5 });
() => ep({ group: 5 });
() => ep({ id: 5, group: 5 });
() => epbody({ title: 'hi' });
() => epbody({ id: 5 }, { title: 'hi' });
() => epbody({ group: 5 }, { title: 'hi' });
() => epbody({ id: 5, group: 5 }, { title: 'hi' });
// @ts-expect-error
() => epbody({ title: 'hi' }, { title: 'hi' });
});
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 wildcard (*) path params as arrays', () => {
const ep = new RestEndpoint({ path: '/files/*path' });
expect(ep.url({ path: ['a', 'b', 'c'] })).toBe('/files/a/b/c');
expect(ep.url({ path: ['single'] })).toBe('/files/single');
// @ts-expect-error - path must be an array, not a string
() => ep.url({ path: 'a/b/c' });
// @ts-expect-error - missing required wildcard param
() => ep.url({});
});
it('should handle wildcard with regular params', () => {
const ep = new RestEndpoint({ path: '/users/:id/*rest' });
expect(ep.url({ id: '5', rest: ['docs', 'file'] })).toBe(
'/users/5/docs/file',
);
expect(ep.url({ id: '42', rest: ['a'] })).toBe('/users/42/a');
// @ts-expect-error - missing id
() => ep.url({ rest: ['a'] });
// @ts-expect-error - missing rest
() => ep.url({ id: '5' });
});
it('should handle optional wildcard params', () => {
const ep = new RestEndpoint({ path: '/files{/*path}' });
expect(ep.url({ path: ['a', 'b'] })).toBe('/files/a/b');
expect(ep.url({})).toBe('/files');
expect(ep.url({ path: undefined })).toBe('/files');
() => ep();
});
it('should handle optional wildcard with required param', () => {
const ep = new RestEndpoint({ path: '/users/:id{/*rest}' });
expect(ep.url({ id: '5', rest: ['docs', 'file'] })).toBe(
'/users/5/docs/file',
);
expect(ep.url({ id: '5' })).toBe('/users/5');
expect(ep.url({ id: '5', rest: undefined })).toBe('/users/5');
// @ts-expect-error - missing required param id
() => ep.url({});
// @ts-expect-error - rest should be array, not string
() => ep.url({ id: '5', rest: 'docs/file' });
});
it('should handle escaped special characters in path', () => {
const ep = new RestEndpoint({
path: '/users/:login/events/public\\?per_page=50',
});
expect(ep.url({ login: 'alice' })).toBe(
'/users/alice/events/public?per_page=50',
);
const ep2 = new RestEndpoint({
path: '/search\\?q=hello\\&page=2',
});
expect(ep2.url()).toBe('/search?q=hello&page=2');
});
it('should allow sideEffect overrides', () => {
const weirdGetUser = new RestEndpoint({
path: 'http\\://test.com/user/:id',
name: 'getter',
schema: User,
method: 'POST',
sideEffect: undefined,
});
expect(weirdGetUser.sideEffect).toBe(undefined);
const a: undefined = weirdGetUser.sideEffect;
// @ts-expect-error
const y: true = weirdGetUser.sideEffect;
});
it('should handle simple urls', () => {
expect(getUser.url({ id: '5' })).toBe('http://test.com/user/5');
expect(getUser.url({ id: '100' })).toBe('http://test.com/user/100');
// @ts-expect-error
() => getUser.url({ sdf: '5' });
});
it('should handle multiarg urls', () => {
const getMyUser = new RestEndpoint({
path: 'http\\://test.com/groups/:group/users/:id',
schema: User,
method: 'GET',
extra: 5,
});
expect(getMyUser.url({ group: 'big', id: '5' })).toBe(
'http://test.com/groups/big/users/5',
);
expect(getMyUser.url({ group: 'big', id: '100' })).toBe(
'http://test.com/groups/big/users/100',
);
// missing required
expect(() =>
// @ts-expect-error
getMyUser.url({ id: '5' }),
).toThrow();
// extra fields
() =>
getMyUser.url({
group: 'mygroup',
id: '5',
// @ts-expect-error
notexisting: 'hi',
});
// @ts-expect-error
() => useSuspense(getMyUser, { id: '5' });
// @ts-expect-error
() => useSuspense(getMyUser);
() => useSuspense(getMyUser, { group: 'yay', id: '5' });
});
it('should automatically name methods', () => {
expect(getUser.name).toBe('User.get');
expect(getArticleList.name).toMatchInlineSnapshot(
`"http://test.com/article-paginated"`,
);
expect(
getArticleList.extend({ path: '/:something' }).name,
).toMatchInlineSnapshot(`"http://test.com/:something"`);
});
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, controller } = renderDataClient(() => {
const { fetch } = useController();
const {
data: { results: articles },
nextPage,
} = useSuspense(getArticleList);
return { articles, nextPage, fetch };
});
await waitForNextUpdate();
// @ts-expect-error
() => controller.fetch(getNextPage);
// @ts-expect-error
() => controller.fetch(getNextPage, { fake: 5 });
expect(result.current.nextPage).toEqual(paginatedFirstPage.nextPage);
await controller.fetch(getNextPage, {
cursor: result.current.nextPage,
});
expect(result.current.articles.map(({ id }) => id)).toEqual([5, 3, 7, 8]);
expect(result.current.nextPage).toBeUndefined();
});
it('should update on get for a paginated resource with parameter in path', async () => {
mynock.get(`/article-paginated/happy`).reply(200, paginatedFirstPage);
mynock
.get(`/article-paginated/happy?cursor=2`)
.reply(200, paginatedSecondPage);
const { result, waitForNextUpdate, controller } = renderDataClient(() => {
const {
data: { results: articles },
nextPage,
} = useSuspense(getArticleList2, {
group: 'happy',
});
return { articles, nextPage };
});
await waitForNextUpdate();
// @ts-expect-error
() => controller.fetch(getNextPage2);
// @ts-expect-error
() => controller.fetch(getNextPage2, { fake: 5 });
// @ts-expect-error
() => controller.fetch(getNextPage2, { group: 'happy' });
// @ts-expect-error
() => controller.fetch(getNextPage2, { cursor: 2 });
await controller.fetch(getNextPage2, {
group: 'happy',
cursor: 2,
});
expect(result.current.articles.map(({ id }) => id)).toEqual([5, 3, 7, 8]);
});
it('push: should extend name of parent endpoint', () => {
expect(getArticleList3.push.name).toMatchSnapshot();
expect(getArticleList3.push.name).toBe(getArticleList3.unshift.name);
});
it('unshift: should extend name of parent endpoint', () => {
expect(getArticleList3.unshift.name).toMatchSnapshot();
});
it('remove: should extend name of parent endpoint', () => {
expect(getArticleList3.remove.name).toMatchSnapshot();
});
it('move: should extend name of parent endpoint', () => {
expect(getArticleList3.move.name).toMatchSnapshot();
});
it('move should work with deeply nested Collection', async () => {
class GroupedArticle extends Entity {
readonly id: number | undefined = undefined;
readonly title: string = '';
readonly group: string = '';
}
const getGroupedArticles = new RestEndpoint({
urlPrefix: 'http://test.com',
path: '/grouped-articles',
movePath: '/grouped-articles/:id',
schema: {
nextPage: '',
data: { results: new Collection([GroupedArticle]) },
},
method: 'GET',
searchParams: {} as { group: string },
});
// Verify schema is correctly extracted from nested structure
expect(getGroupedArticles.move.schema).toBeTruthy();
expect(getGroupedArticles.move.method).toBe('PATCH');
// Diagnose schema type - should not be any
// @ts-expect-error - schema should not be any
getGroupedArticles.move.schema satisfies number;
mynock.patch(/grouped-articles/).reply(200, (uri, body: any) => ({
id: 2,
title: 'Article 2',
group: 'beta',
...(typeof body === 'string' ? JSON.parse(body) : body),
}));
const { result, controller } = renderDataClient(
() => {
return {
alpha: useCache(getGroupedArticles, { group: 'alpha' }),
beta: useCache(getGroupedArticles, { group: 'beta' }),
};
},
{
initialFixtures: [
{
endpoint: getGroupedArticles,
args: [{ group: 'alpha' }],
response: {
nextPage: '2',
data: {
results: [
{ id: 1, title: 'Article 1', group: 'alpha' },
{ id: 2, title: 'Article 2', group: 'alpha' },
],
},
},
},
{
endpoint: getGroupedArticles,
args: [{ group: 'beta' }],
response: {
nextPage: '2',
data: {
results: [{ id: 3, title: 'Article 3', group: 'beta' }],
},
},
},
],
},
);
expect(result.current.alpha?.data.results).toHaveLength(2);
expect(result.current.beta?.data.results).toHaveLength(1);
// Move article 2 from alpha to beta
await act(async () => {
const response = await controller.fetch(
getGroupedArticles.move,
{ id: 2 },
{
id: 2,
title: 'Article 2',
group: 'beta',
},
);
// Type test: response should be a single GroupedArticle, not an array
response.title satisfies string;
expect(response.title).toBe('Article 2');
expect(response.group).toBe('beta');
});
// article should be removed from alpha
expect(result.current.alpha?.data.results).toHaveLength(1);
const alphaResults = result.current.alpha!.data.results!;
expect(alphaResults[0]?.title).toBe('Article 1');
// article should be added to beta
const betaResults = result.current.beta!.data.results!;
expect(betaResults).toHaveLength(2);
expect(betaResults.map((a: any) => a.title)).toEqual(
expect.arrayContaining(['Article 3', 'Article 2']),
);
});
// TODO: but we need a Values collection
// it('assign: should extend name of parent endpoint', () => {
// expect(getArticleList3.assign.name).toMatchSnapshot();
// });
it('getPage: should extend name of parent endpoint', () => {
expect(getNextPage3.name).toMatchSnapshot();
});
it('getPage: should update on get for a paginated resource with parameter in path', async () => {
mynock.get(`/article-paginated?group=happy`).reply(200, paginatedFirstPage);
mynock
.get(`/article-paginated?cursor=2&group=happy`)
.reply(200, paginatedSecondPage);
const { result, waitForNextUpdate, controller } = renderDataClient(() => {
const {
data: { results: articles },
nextPage,
} = useSuspense(getArticleList3, {
group: 'happy',
});
return { articles, nextPage };
});
await waitForNextUpdate();
// @ts-expect-error
() => controller.fetch(getNextPage3);
// @ts-expect-error
() => controller.fetch(getNextPage3, { fake: 5 });
// @ts-expect-error
() => controller.fetch(getNextPage3, { group: 'happy' });
// @ts-expect-error
() => controller.fetch(getNextPage3, { cursor: 2 });
await controller.fetch(getNextPage3, {
group: 'happy',
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(getArticleList);
return { articles, nextPage, fetch };
});
await waitForNextUpdate();
await result.current.fetch(getNextPage, {
cursor: 2,
});
//TODO: Why is this broken? 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 getComplex = new RestEndpoint({
path: '/complex-thing/:id',
schema: ComplexEntity,
method: 'GET',
});
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(getComplex, { 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(getComplex, {
id: '5',
});
expect(result.current.article).toEqual({ ...secondResponse, extra: 'hi' });
});
it('overriding methods should work', async () => {
mynock.put(`/user/5`).reply(200, (uri, body: any) => ({
id: 5,
username: 'bob',
...body,
}));
const updateUser = new RestEndpoint({
method: 'PUT',
path: 'http\\://test.com/user/:id',
name: 'get',
schema: User,
getRequestInit(body) {
if (body && isPojo(body)) {
return RestEndpoint.prototype.getRequestInit.call(this, {
...body,
email: 'always@always.com',
});
}
return RestEndpoint.prototype.getRequestInit.call(this, body);
},
});
const response = await updateUser(
{ id: 5 },
{ username: 'micky', email: 'micky@gmail.com' },
);
expect(response).toMatchInlineSnapshot(`
{
"email": "always@always.com",
"id": 5,
"username": "micky",
}
`);
});
describe('class extensions', () => {
class DefaultUser extends User {
defaultUserExtra = 'yay';
}
class MyEndpoint extends RestEndpoint<
Defaults
> {
constructor(options: Readonly & O>) {
super({ schema: DefaultUser, ...options } as any);
}
parseResponse(response: Response): Promise {
return super.parseResponse(response);
}
getRequestInit(body: any) {
if (isPojo(body)) {
return super.getRequestInit({ ...body, email: 'always@always.com' });
}
return super.getRequestInit(body);
}
additional = 5;
}
it('should work with constructor', async () => {
mynock.put('/user/5').reply(200, (uri, body: any) => ({
id: 5,
username: 'bob',
...body,
}));
const updateUser = new MyEndpoint({
method: 'PUT',
path: 'http\\://test.com/user/:id',
name: 'update',
schema: User,
});
const response = await updateUser(
{ id: 5 },
{ username: 'micky', email: 'micky@gmail.com' },
);
expect(response).toMatchInlineSnapshot(`
{
"email": "always@always.com",
"id": 5,
"username": "micky",
}
`);
expect(updateUser.additional).toBe(5);
});
it('setting body in extend should work', async () => {
mynock.put('/charmer/5').reply(200, (uri, body: any) => ({
id: 5,
username: 'bob',
...body,
}));
const updateUser = new MyEndpoint({
method: 'PUT',
path: 'http\\://test.com/user/:id',
name: 'update',
schema: User,
}).extend({
body: 5,
path: 'http\\://test.com/charmer/:charm',
});
() => {
// test type widening
const second = updateUser.extend({ body: { body: '' } });
second({ charm: 5 }, { body: 'hi' });
};
const response = await updateUser(
{ charm: 5 },
// @ts-expect-error
{ username: 'micky', email: 'micky@gmail.com' },
);
() => updateUser({ charm: 5 }, 5);
expect(response).toMatchInlineSnapshot(`
{
"email": "always@always.com",
"id": 5,
"username": "micky",
}
`);
expect(updateUser.additional).toBe(5);
const nobody = updateUser.extend({
path: 'http\\://test.com/user/:charm',
body: undefined,
});
() => nobody({ charm: 5 });
// @ts-expect-error
() => nobody({ id: 5 });
});
it('setting body in extend should work without path', async () => {
mynock.put('/user/5').reply(200, (uri, body: any) => ({
id: 5,
username: 'bob',
...body,
}));
const updateUser = new MyEndpoint({
method: 'PUT',
path: 'http\\://test.com/user/:id',
name: 'update',
schema: User,
}).extend({
body: 5,
});
const response = await updateUser(
{ id: 5 },
// @ts-expect-error
{ username: 'micky', email: 'micky@gmail.com' },
);
() => updateUser({ id: 5 }, 5);
expect(response).toMatchInlineSnapshot(`
{
"email": "always@always.com",
"id": 5,
"username": "micky",
}
`);
expect(updateUser.additional).toBe(5);
const nobody = updateUser.extend({
path: 'http\\://test.com/user/:charm',
body: undefined,
});
() => nobody({ charm: 5 });
// @ts-expect-error
() => nobody({ id: 5 });
const updateUser2 = new MyEndpoint({
method: 'PUT',
path: 'http\\://test.com/user/:id',
name: 'update',
schema: User,
}).extend({
searchParams: {} as { isAdmin: boolean },
});
() =>
updateUser2(
{ id: 5, isAdmin: true },
{ username: 'micky', email: 'micky@gmail.com' },
);
() =>
// @ts-expect-error
updateUser2({ id: 5 }, { username: 'micky', email: 'micky@gmail.com' });
() =>
updateUser2(
// @ts-expect-error
{ isAdmin: true },
{ username: 'micky', email: 'micky@gmail.com' },
);
const updateBasic = new MyEndpoint({
method: 'PUT',
path: 'http\\://test.com/user/:id',
name: 'update',
schema: User,
});
() =>
updateBasic({ id: 5 }, { username: 'micky', email: 'micky@gmail.com' });
() =>
updateBasic(
// @ts-expect-error
{ id: 5, isAdmin: true },
{ username: 'micky', email: 'micky@gmail.com' },
);
});
it('should work with default schema in class definition', async () => {
mynock.get('/user/5').reply(200, (uri, body: any) => ({
id: 5,
username: 'bob',
email: 'bob@gmail.com',
}));
const getUser = new MyEndpoint({
path: 'http\\://test.com/user/:id',
name: 'update',
});
const { result, waitForNextUpdate } = renderDataClient(() => {
return useSuspense(getUser, { id: 5 });
});
await waitForNextUpdate();
expect(result.current.username).toBe('bob');
// @ts-expect-error
expect(result.current.sdfsd).toBeUndefined();
expect(result.current.defaultUserExtra).toBe('yay');
expect(result.current.email).toBe('bob@gmail.com');
});
it('should work with default path in class definition', async () => {
mynock.get('/user/5').reply(200, (uri, body: any) => ({
id: 5,
username: 'bob',
email: 'bob@gmail.com',
}));
// this seems like a less common use case; so we're fine with it being annoying
class UserEndpoint<
O extends Partial = {
schema: DefaultUser;
path: 'http\\://test.com/user/:id';
},
> extends MyEndpoint<
Defaults
> {
constructor({
path = 'http\\://test.com/user/:id',
...options
}: Readonly) {
super({ path, ...options } as any);
}
}
const getUser = new UserEndpoint({
method: 'GET',
name: 'update',
});
const { result, waitForNextUpdate } = renderDataClient(() => {
return useSuspense(getUser, { id: 5 });
});
await waitForNextUpdate();
expect(result.current.username).toBe('bob');
// @ts-expect-error
expect(result.current.sdfsd).toBeUndefined();
expect(result.current.defaultUserExtra).toBe('yay');
expect(result.current.email).toBe('bob@gmail.com');
// @ts-expect-error
() => getUser({ group: 5 });
// @ts-expect-error
() => useSuspense(getUser, { group: 5 });
// @ts-expect-error
() => useSuspense(getUser);
});
it('update should work with extends', async () => {
mynock.put('/6/user/5').reply(200, (uri, body: any) => ({
id: 5,
username: 'charles',
...body,
}));
const updateUser = new MyEndpoint({
method: 'PUT',
path: 'http\\://test.com/user/:id',
name: 'update',
schema: User,
}).extend({
path: 'http\\://test.com/:group/user/:id',
body: 0 as Partial,
});
expect(updateUser.additional).toBe(5);
expect(updateUser.method).toBe('PUT');
const response = await updateUser(
{ group: '6', id: 5 },
{ email: 'micky@gmail.com' },
);
expect(response).toMatchInlineSnapshot(`
{
"email": "always@always.com",
"id": 5,
"username": "charles",
}
`);
});
it('get should work with extends', async () => {
mynock.get('/6/user/5').reply(200, (uri, body: any) => ({
id: 5,
username2: 'charles',
...body,
}));
class User2 extends Entity {
readonly id: number | undefined = undefined;
readonly username2: string = '';
readonly email: string = '';
readonly isAdmin: boolean = false;
}
const getUserBase = new MyEndpoint({
method: 'GET',
path: 'http\\://test.com/user/:id',
name: 'getuser',
schema: User,
});
const getUser = getUserBase.extend({
path: 'http\\://test.com/:group/user/:id',
schema: User2,
});
getUserBase.body;
expect(getUserBase.name).toBe('getuser');
expect(getUserBase.extend({ method: 'GET' }).name).toBe('getuser');
expect(getUser.name).toBe('getuser');
expect(getUser.additional).toBe(5);
expect(getUser.method).toBe('GET');
const user = await getUser({ group: '6', id: 5 });
expect(user.username2).toBe('charles');
() => {
const a = useSuspense(getUser, { group: '6', id: 5 });
// @ts-expect-error
a.username;
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
expect(user.username).toBeUndefined();
expect(user).toMatchInlineSnapshot(`
{
"id": 5,
"username2": "charles",
}
`);
const newBody = getUser
.extend({
body: {} as { title: string },
dataExpiryLength: 0,
method: 'POST',
})
.extend({ dataExpiryLength: 5 });
() => newBody({ group: 'hi', id: 'what' }, { title: 'cool' });
// @ts-expect-error
() => newBody({ id: 'what' }, { title: 'cool' });
// @ts-expect-error
() => newBody({ title: 'cool' });
// @ts-expect-error
() => newBody({ group: 'hi', id: 'what' });
// @ts-expect-error
() => newBody({ group: 'hi', id: 'what' }, { sdfsd: 'cool' });
const bodyNoPath = newBody.extend({
path: '/',
});
const bodyNoParams = bodyNoPath.extend({
body: {} as { happy: string },
});
() => bodyNoParams({ happy: 'cool' });
// @ts-expect-error
() => bodyNoParams({ group: 'hi', id: 'what' }, { happy: 'cool' });
// @ts-expect-error
() => bodyNoParams({ group: 'hi', id: 'what' }, { title: 'cool' });
// @ts-expect-error
() => bodyNoParams({ sdfd: 'cool' });
const searchParams = getUser.extend({
path: 'http\\://test.com/:group/user/:id',
searchParams: {} as { isAdmin?: boolean; sort: 'asc' | 'desc' },
});
() => searchParams({ group: 'hi', id: 'what', sort: 'asc' });
() =>
searchParams({ group: 'hi', id: 'what', sort: 'asc', isAdmin: true });
// @ts-expect-error
() => searchParams({ group: 'hi', id: 'what', sort: 'abc' });
// @ts-expect-error
() => searchParams({ group: 'hi', id: 'what' });
// @ts-expect-error
() => searchParams.url({ group: 'hi', id: 'what' });
expect(
searchParams.url({
group: 'hi',
id: 'what',
sort: 'desc',
isAdmin: true,
}),
).toMatchInlineSnapshot(
`"http://test.com/hi/user/what?isAdmin=true&sort=desc"`,
);
const searchParams2 = searchParams.extend({
searchParams: {} as { bigger: boolean },
});
() => searchParams2({ group: 'hi', id: 'what', bigger: true });
() => searchParams2({ group: 'hi', id: 'what', bigger: false });
// @ts-expect-error
() => searchParams2({ group: 'hi', id: 'what', bigger: 5 });
// @ts-expect-error
() => searchParams2({ group: 'hi', id: 'what', sort: 'asc' });
// @ts-expect-error
() => searchParams2({ group: 'hi', id: 'what' });
// @ts-expect-error
() => searchParams2.url({ group: 'hi', id: 'what' });
expect(
searchParams2.url({
group: 'hi',
id: 'what',
bigger: true,
}),
).toMatchInlineSnapshot(`"http://test.com/hi/user/what?bigger=true"`);
const searchParams3 = getUserBase.extend({
searchParams: {} as { bigger: boolean },
});
() => searchParams3({ id: 'what', bigger: true });
() => searchParams3({ id: 'what', bigger: false });
// @ts-expect-error
() => searchParams3({ id: 'what', bigger: 5 });
// @ts-expect-error
() => searchParams3({ id: 'what', sort: 'asc' });
// @ts-expect-error
() => searchParams3({ id: 'what' });
// @ts-expect-error
() => searchParams3.url({ id: 'what' });
expect(
searchParams3.url({
id: 'what',
bigger: true,
}),
).toMatchInlineSnapshot(`"http://test.com/user/what?bigger=true"`);
const searchParams4 = getUserBase
.extend({
path: '/users',
})
.extend({ searchParams: {} as { bigger?: boolean } | undefined });
() => searchParams4({ bigger: true });
() => searchParams4();
() => searchParams4({});
// @ts-expect-error
() => searchParams4({ id: 'what', bigger: false });
// @ts-expect-error
() => searchParams4({ bigger: 5 });
// @ts-expect-error
() => searchParams4({ id: 'what' });
// @ts-expect-error
() => searchParams4.url({ id: 'what' });
expect(
searchParams4.url({
bigger: true,
}),
).toMatchInlineSnapshot(`"/users?bigger=true"`);
expect(searchParams4.url()).toMatchInlineSnapshot(`"/users"`);
});
it('should work with custom searchToString', async () => {
class SearchEndpoint extends MyEndpoint {
searchToString(searchParams: Record) {
return super.searchToString({ ...searchParams, bob: 5 });
}
}
mynock.get('/6/user/5').reply(200, (uri, body: any) => ({
id: 5,
username2: 'charles',
...body,
}));
class User2 extends Entity {
readonly id: number | undefined = undefined;
readonly username2: string = '';
readonly email: string = '';
readonly isAdmin: boolean = false;
pk() {
return this.id?.toString();
}
}
const getUserBase = new SearchEndpoint({
method: 'GET',
path: 'http\\://test.com/user/:id',
name: 'getuser',
schema: User,
});
const getUser = getUserBase.extend({
path: 'http\\://test.com/:group/user/:id',
schema: User2,
});
const searchParams = getUser.extend({
path: 'http\\://test.com/:group/user/:id',
searchParams: {} as { isAdmin?: boolean; sort: 'asc' | 'desc' },
});
expect(
searchParams.url({
group: 'hi',
id: 'what',
sort: 'desc',
isAdmin: true,
}),
).toMatchInlineSnapshot(
`"http://test.com/hi/user/what?bob=5&isAdmin=true&sort=desc"`,
);
});
});
it('extending with name should work', () => {
const endpoint = CoolerArticleResource.get.extend({ name: 'mything' });
const endpoint2 = CoolerArticleResource.get.extend({ path: '/:bob' });
expect(CoolerArticleResource.get.name).toMatchInlineSnapshot(
`"CoolerArticle.get"`,
);
expect(endpoint.name).toBe('mything');
expect(endpoint2.name).toMatchInlineSnapshot(`"CoolerArticle.get"`);
});
it('should infer default method when sideEffect is set', async () => {
const endpoint = new RestEndpoint({
sideEffect: true,
path: 'http\\://test.com/article-cooler',
schema: CoolerArticle,
}).extend({ name: 'createarticle' });
const a: true = endpoint.sideEffect;
const b: 'POST' = endpoint.method;
expect(endpoint.method).toBe('POST');
expect(endpoint.sideEffect).toBe(true);
const article = await endpoint(payload);
expect(article).toMatchInlineSnapshot(`
{
"content": "whatever",
"id": 1,
"tags": [
"a",
"best",
"react",
],
"title": "hi ho",
}
`);
});
describe('body type setting', () => {
it('should work in constructors', () => {
interface TodoInterface {
title: string;
completed: boolean;
}
const update = new RestEndpoint({
path: '/:id',
method: 'POST',
body: {} as TodoInterface,
});
() => update({ id: 5 }, { title: 'updated', completed: true });
// @ts-expect-error
() => update({ id: 5 });
// @ts-expect-error
() => update({ id: 5 }, { title: 5, completed: true });
// @ts-expect-error
() => update({ id: 5 }, { completed: true });
});
});
describe('process() return type setting', () => {
const getArticle = new RestEndpoint({
path: 'http\\://test.com/article-cooler/:id',
process(value): CoolerArticle {
return value;
},
});
it('should work with constructors', async () => {
const article = await getArticle({ id: '5' });
article.author;
// @ts-expect-error
article.asdf;
() => useSuspense(getArticle, { id: '5' }).content;
// @ts-expect-error
() => useSuspense(getArticle, { id: '5' }).asdf;
});
it('should set on .extend()', async () => {
const getExtends = new RestEndpoint({
path: 'http\\://test.com/article-cooler/:id',
}).extend({
process(value: any): CoolerArticle {
return value;
},
});
const ex = await getExtends({ id: '5' });
ex.author;
// @ts-expect-error
ex.asdf;
() => useSuspense(getExtends, { id: '5' }).content;
// @ts-expect-error
() => useSuspense(getExtends, { id: '5' }).asdf;
});
it('should override existing type on .extend()', async () => {
const getOverride = getArticle.extend({
process(value: any, param: any): { asdf: string } {
return value;
},
});
const ov = await getOverride({ id: '5' });
ov.asdf;
// @ts-expect-error
ov.author;
() => useSuspense(getOverride, { id: '5' }).asdf;
// @ts-expect-error
() => useSuspense(getOverride, { id: '5' }).content;
});
it('should maintain existing type on .extend() when not specified', async () => {
const getOverride = getArticle.extend({
dataExpiryLength: 7,
});
const ov = await getOverride({ id: '5' });
ov.author;
// @ts-expect-error
ov.asdf;
});
it('should maintain existing type on .extend() when process is not supplied but path is', async () => {
const getOverride = getArticle.extend({
path: '/:a/:b',
});
async () => {
const ov = await getOverride({ a: '5', b: '7' });
ov.author;
// @ts-expect-error
ov.asdf;
};
});
it('should override existing type on .extend() when path is also supplied', async () => {
const getOverride = getArticle.extend({
path: '/:a/:b',
process(value: any, param: any): { asdf: string } {
return value;
},
});
async () => {
const ov = await getOverride({ a: '5', b: '7' });
ov.asdf;
// @ts-expect-error
ov.author;
};
() => useSuspense(getOverride, { a: '5', b: '7' }).asdf;
// @ts-expect-error
() => useSuspense(getOverride, { a: '5', b: '7' }).content;
});
});
it('extend options should match function of path set', () => {
const endpoint = new RestEndpoint({
sideEffect: true,
path: 'http\\://test.com/article-cooler/:id',
body: 0 as any,
schema: CoolerArticle,
getOptimisticResponse(snap, params, body) {
params.id;
// @ts-expect-error
params.two;
body.hi;
},
}).extend({
path: '/:group/next/:two',
body: undefined,
getOptimisticResponse(snap, params) {
params.two;
params.group;
// @ts-expect-error
params.id;
},
});
const endpoint2 = new RestEndpoint({
sideEffect: true,
path: 'http\\://test.com/article-cooler/:id',
body: 0 as any,
schema: CoolerArticle,
getOptimisticResponse(snap, params, body) {
params.id;
// @ts-expect-error
params.two;
body.hi;
},
}).extend({
getOptimisticResponse(snap, params, body) {
params.id;
// @ts-expect-error
params.two;
body.hi;
},
});
});
});
describe('RestEndpoint.fetch()', () => {
const id = 5;
const idHtml = 6;
const idNoContent = 7;
const payload = {
id,
title: 'happy',
author: User.fromJS({ id: 5 }),
};
const putResponseBody = {
id,
title: 'happy',
completed: true,
};
const patchPayload = {
title: 'happy',
};
const patchResponseBody = {
id,
title: 'happy',
completed: false,
};
beforeEach(() => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
})
.options(/.*/)
.reply(200)
.get(`/article-cooler/${payload.id}`)
.reply(200, payload)
.get(`/article-cooler/${idHtml}`)
.reply(200, 'this is html')
.get(`/article-cooler/${idNoContent}`)
.reply(204, '')
.post('/article-cooler')
.reply((uri, requestBody) => [
201,
requestBody,
{ 'content-type': 'application/json' },
])
.put('/article-cooler/5')
.reply((uri, requestBody) => {
let body = requestBody as any;
if (typeof requestBody === 'string') {
body = JSON.parse(requestBody);
}
for (const key of Object.keys(CoolerArticle.fromJS({}))) {
if (key !== 'id' && !(key in body)) {
return [400, {}, { 'content-type': 'application/json' }];
}
}
return [200, putResponseBody, { 'content-type': 'application/json' }];
})
.patch('/article-cooler/5')
.reply(() => [
200,
patchResponseBody,
{ 'content-type': 'application/json' },
])
.intercept('/article-cooler/5', 'DELETE')
.reply(200, {});
});
afterEach(() => {
nock.cleanAll();
});
it('should GET', async () => {
const article = await CoolerArticleResource.get({
id: payload.id,
});
expect(article).toBeDefined();
if (!article) {
throw new Error('ahh');
}
expect(article.title).toBe(payload.title);
});
it('should POST', async () => {
const payload2 = { id: 20, content: 'better task' };
const article = await CoolerArticleResource.create(payload2);
expect(article).toMatchObject(payload2);
});
it('should PUT with multipart form data', async () => {
const payload2 = { id: 500, content: 'another' };
let lastRequest: any;
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
})
.put('/article-cooler/500')
.reply(function (uri, requestBody) {
lastRequest = this.req;
return [201, payload2, { 'Content-Type': 'application/json' }];
});
const newPhoto = new Blob();
const body = new FormData();
body.append('photo', newPhoto);
const article = await CoolerArticleResource.update.extend({
path: CoolerArticleResource.update.path,
body: new FormData(),
})({ id: '500' }, body);
expect(lastRequest.headers['content-type']).toContain(
'multipart/form-data',
);
expect(article).toMatchObject(payload2);
});
it('should DELETE', async () => {
const res = await CoolerArticleResource.delete({
id: payload.id,
});
expect(res).toEqual({ id });
});
it('should PUT', async () => {
const response = await CoolerArticleResource.update(
{ id: payload.id },
{ ...CoolerArticle.fromJS(payload) },
);
expect(response).toEqual(putResponseBody);
});
it('should PATCH', async () => {
const response = await CoolerArticleResource.partialUpdate(
{ id },
patchPayload,
);
expect(response).toEqual(patchResponseBody);
});
it('should throw if response is not json', async () => {
let error: any;
try {
await CoolerArticleResource.get({ id: idHtml });
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.status).toBe(400);
});
it('should throw if network is down', async () => {
const oldError = console.error;
console.error = () => {};
const id = 10;
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
})
.get(`/article-cooler/${id}`)
.replyWithError(new TypeError('Network Down'));
let error: any;
try {
await CoolerArticleResource.get({ id });
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.status).toBe(500);
console.error = oldError;
});
it('should return raw response if status is 204 No Content', async () => {
const res = await CoolerArticleResource.get({ id: idNoContent });
expect(res).toBe(null);
});
it('should reject if content-type is not json with schema', async () => {
const id = 8;
const text = 'this is html';
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
})
.get(`/article-cooler/${id}`)
.reply(200, text, { 'content-type': 'html/text' });
await expect(
async () => await CoolerArticleResource.get({ id }),
).rejects.toMatchSnapshot();
});
it('should reject if content-type does not exist with schema', async () => {
const id = 10;
const text = 'this is html';
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
})
.get(`/article-cooler/${id}`)
.reply(200, text, {});
await expect(
async () => await CoolerArticleResource.get({ id }),
).rejects.toMatchSnapshot();
});
it('should return text if content-type is not json with no schema', async () => {
const id = 8;
const text = 'this is html';
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
})
.get(`/article-cooler/${id}`)
.reply(200, text, { 'content-type': 'html/text' });
const res = await CoolerArticleResource.get.extend({ schema: undefined })({
id,
});
expect(res).toBe('this is html');
});
it('should return text if content-type does not exist with no schema', async () => {
const id = 10;
const text = 'this is html';
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
})
.get(`/article-cooler/${id}`)
.reply(200, text, {});
const res = await CoolerArticleResource.get.extend({ schema: undefined })({
id,
});
expect(res).toBe(text);
});
it('should reject with custom message if content type is set but json parsable', async () => {
const id = 8;
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'text',
})
.get(`/article-cooler/${id}`)
.reply(200, { id, title: 'hi' }, {});
await expect(
async () => await CoolerArticleResource.get({ id }),
).rejects.toMatchSnapshot();
});
it('should still work with empty content-type', async () => {
const id = 8;
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
})
.get(`/article-cooler/${id}`)
.reply(200, { id, title: 'hi' });
const res = await CoolerArticleResource.get({
id,
});
expect(res).toEqual({ id, title: 'hi' });
});
it('without Collection in schema - endpoint.push schema should be null', () => {
const noColletionEndpoint = new RestEndpoint({
urlPrefix: 'http://test.com/article-paginated/',
path: ':group',
name: 'get',
schema: {
nextPage: '',
data: { results: [PaginatedArticle] },
},
method: 'GET',
});
expect(noColletionEndpoint.push.schema).toBeFalsy();
});
it('without Collection in schema - endpoint.remove schema should be null', () => {
const noColletionEndpoint = new RestEndpoint({
urlPrefix: 'http://test.com/article-paginated/',
path: ':group',
name: 'get',
schema: {
nextPage: '',
data: { results: [PaginatedArticle] },
},
method: 'GET',
});
expect(noColletionEndpoint.remove.schema).toBeFalsy();
});
it('without Collection in schema - endpoint.move schema should be null', () => {
const noColletionEndpoint = new RestEndpoint({
urlPrefix: 'http://test.com/article-paginated/',
path: ':group',
name: 'get',
schema: {
nextPage: '',
data: { results: [PaginatedArticle] },
},
method: 'GET',
});
expect(noColletionEndpoint.move.schema).toBeFalsy();
});
it('without Collection in schema - endpoint.getPage should throw', () => {
const noColletionEndpoint = new RestEndpoint({
urlPrefix: 'http://test.com/article-paginated/',
path: ':group',
name: 'get',
schema: {
nextPage: '',
data: { results: [PaginatedArticle] },
},
method: 'GET',
});
expect(() => noColletionEndpoint.getPage).toThrowErrorMatchingSnapshot();
});
});
describe('content property', () => {
const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
it("content: 'blob' calls response.blob()", async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/octet-stream',
})
.get('/files/1')
.reply(200, binaryData);
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
content: 'blob',
});
const result = await ep({ id: 1 });
expect(result).toBeInstanceOf(Blob);
});
it("content: 'text' calls response.text()", async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'text/plain',
})
.get('/files/1')
.reply(200, 'hello world');
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
content: 'text',
});
const result = await ep({ id: 1 });
expect(result).toBe('hello world');
});
it("content: 'json' calls response.json() strictly", async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'text/plain',
})
.get('/files/1')
.reply(200, { ok: true });
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
content: 'json',
});
const result = await ep({ id: 1 });
expect(result).toEqual({ ok: true });
});
it('content: blob with schema set throws', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/octet-stream',
})
.get('/files/1')
.reply(200, binaryData);
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
content: 'blob',
schema: User,
} as any);
await expect(async () => await ep({ id: 1 })).rejects.toMatchObject({
status: 400,
message: expect.stringContaining('incompatible with schema'),
});
});
it.each(['blob', 'text', 'arrayBuffer', 'stream'] as const)(
'content: %s with schema warns at construction',
content => {
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
try {
new RestEndpoint({
path: 'http\\://test.com/files/:id',
content,
schema: User,
} as any);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('incompatible with schema'),
);
} finally {
errorSpy.mockRestore();
}
},
);
it('204 with content: blob returns null', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
})
.get('/files/1')
.reply(204);
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
content: 'blob',
});
const result = await ep({ id: 1 });
expect(result).toBe(null);
});
it("extend({ content: 'blob' }) propagates", async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/octet-stream',
})
.get('/files/1')
.reply(200, binaryData);
const base = new RestEndpoint({
path: 'http\\://test.com/files/:id',
});
const blobEp = base.extend({ content: 'blob' });
expect(blobEp.content).toBe('blob');
const result = await blobEp({ id: 1 });
expect(result).toBeInstanceOf(Blob);
});
it('subclass with content = blob works', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/octet-stream',
})
.get('/files/1')
.reply(200, binaryData);
class BlobEndpoint extends RestEndpoint {
content = 'blob' as const;
}
const ep = new BlobEndpoint({ path: 'http\\://test.com/files/:id' });
expect(ep.content).toBe('blob');
const result = await ep({ id: 1 });
expect(result).toBeInstanceOf(Blob);
});
it("content: 'arrayBuffer' calls response.arrayBuffer()", async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/octet-stream',
})
.get('/files/1')
.reply(200, Buffer.from([1, 2, 3, 4]));
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
content: 'arrayBuffer',
});
const result = await ep({ id: 1 });
expect(result).toBeInstanceOf(ArrayBuffer);
});
it("content: 'stream' returns response.body", async () => {
const mockBody = {
getReader() {
return {};
},
};
const mockResponse = {
ok: true,
status: 200,
body: mockBody,
headers: new Headers({ 'Content-Type': 'application/octet-stream' }),
} as unknown as Response;
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
content: 'stream',
fetchResponse() {
return Promise.resolve(mockResponse);
},
});
const result = await ep({ id: 1 });
expect(result).toBe(mockBody);
});
});
describe('auto-detection (no content)', () => {
it('Content-Type: image/png returns blob', async () => {
const imgData = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'image/png',
})
.get('/files/1')
.reply(200, imgData);
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
});
const result = await ep({ id: 1 });
expect(result).toBeInstanceOf(Blob);
});
it('Content-Type: application/octet-stream returns blob', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/octet-stream',
})
.get('/files/1')
.reply(200, Buffer.from([1, 2, 3]));
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
});
const result = await ep({ id: 1 });
expect(result).toBeInstanceOf(Blob);
});
it('Content-Type: application/pdf returns blob', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/pdf',
})
.get('/files/1')
.reply(200, Buffer.from([0x25, 0x50, 0x44, 0x46]));
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
});
const result = await ep({ id: 1 });
expect(result).toBeInstanceOf(Blob);
});
it('Content-Type: text/plain returns text (unchanged)', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'text/plain',
})
.get('/files/1')
.reply(200, 'plain text');
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
});
const result = await ep({ id: 1 });
expect(result).toBe('plain text');
});
it('Content-Type: application/json returns JSON (unchanged)', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
})
.get('/files/1')
.reply(200, { id: 1, name: 'test' });
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
});
const result = await ep({ id: 1 });
expect(result).toEqual({ id: 1, name: 'test' });
});
it('Content-Type: application/xml returns text (text-like)', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/xml',
})
.get('/files/1')
.reply(200, 'hi');
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
});
const result = await ep({ id: 1 });
expect(result).toBe('hi');
});
it('Content-Type: audio/mpeg returns blob', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'audio/mpeg',
})
.get('/files/1')
.reply(200, Buffer.from([0xff, 0xfb]));
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
});
const result = await ep({ id: 1 });
expect(result).toBeInstanceOf(Blob);
});
it('Content-Type: video/mp4 returns blob', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'video/mp4',
})
.get('/files/1')
.reply(200, Buffer.from([0x00, 0x00]));
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
});
const result = await ep({ id: 1 });
expect(result).toBeInstanceOf(Blob);
});
it('Content-Type: font/woff2 returns blob', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'font/woff2',
})
.get('/files/1')
.reply(200, Buffer.from([0x77, 0x4f]));
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
});
const result = await ep({ id: 1 });
expect(result).toBeInstanceOf(Blob);
});
it('Content-Type: XLSX (openxmlformats) returns blob', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
.get('/files/1')
.reply(200, Buffer.from([0x50, 0x4b, 0x03, 0x04]));
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
});
const result = await ep({ id: 1 });
expect(result).toBeInstanceOf(Blob);
});
it('Content-Type: DOCX (openxmlformats) returns blob', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type':
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
})
.get('/files/1')
.reply(200, Buffer.from([0x50, 0x4b, 0x03, 0x04]));
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
});
const result = await ep({ id: 1 });
expect(result).toBeInstanceOf(Blob);
});
it('Content-Type: PPTX (openxmlformats) returns blob', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type':
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
})
.get('/files/1')
.reply(200, Buffer.from([0x50, 0x4b, 0x03, 0x04]));
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
});
const result = await ep({ id: 1 });
expect(result).toBeInstanceOf(Blob);
});
it('binary content-type with normalizable schema throws', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/octet-stream',
})
.get('/files/1')
.reply(200, Buffer.from([1, 2, 3]));
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
schema: User,
});
await expect(async () => await ep({ id: 1 })).rejects.toMatchObject({
status: 400,
});
});
it('No Content-Type returns text (backward compat)', async () => {
nock(/.*/)
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
})
.get('/files/1')
.reply(200, 'no content type');
const ep = new RestEndpoint({
path: 'http\\://test.com/files/:id',
});
const result = await ep({ id: 1 });
expect(result).toBe('no content type');
});
});
const proto = Object.prototype;
const gpo = Object.getPrototypeOf;
function isPojo(obj: unknown): obj is Record {
if (obj === null || typeof obj !== 'object') {
return false;
}
return gpo(obj) === proto;
}