Endpoint
Endpoint
are for any asynchronous function (one that returns a Promise).
Endpoints
define a strongly typed standard interface of relevant metadata and lifecycles
useful for Reactive Data Client and other stores.
Package: @data-client/endpoint
Interface
- Interface
- Class
- EndpointExtraOptions
export interface EndpointInterface<
F extends FetchFunction = FetchFunction,
S extends Schema | undefined = Schema | undefined,
M extends true | undefined = true | undefined,
> extends EndpointExtraOptions<F> {
(...args: Parameters<F>): InferReturn<F, S>;
key(...args: Parameters<F>): string;
readonly sideEffect?: M;
readonly schema?: S;
}
class Endpoint<F extends (...args: any) => Promise<any>>
implements EndpointInterface
{
constructor(fetchFunction: F, options: EndpointOptions);
key(...args: Parameters<F>): string;
readonly sideEffect?: true;
readonly schema?: Schema;
fetch: F;
extend(options: EndpointOptions): Endpoint;
}
export interface EndpointOptions extends EndpointExtraOptions {
key?: (params: any) => string;
sideEffect?: true | undefined;
schema?: Schema;
}
export interface EndpointExtraOptions<F extends FetchFunction = FetchFunction> {
/** Default data expiry length, will fall back to NetworkManager default if not defined */
readonly dataExpiryLength?: number;
/** Default error expiry length, will fall back to NetworkManager default if not defined */
readonly errorExpiryLength?: number;
/** Poll with at least this frequency in miliseconds */
readonly pollFrequency?: number;
/** Marks cached resources as invalid if they are stale */
readonly invalidIfStale?: boolean;
/** Enables optimistic updates for this request - uses return value as assumed network response */
readonly getOptimisticResponse?: (
snap: SnapshotInterface,
...args: Parameters<F>
) => ResolveType<F>;
/** Determines whether to throw or fallback to */
readonly errorPolicy?: (error: any) => 'soft' | undefined;
/** User-land extra data to send */
readonly extra?: any;
}
Usage
Endpoint
makes existing async functions usable in any Reactive Data Client context with full TypeScript enforcement.
import { Todo } from './interface'; const getTodoOriginal = (id: number): Promise<Todo> => Promise.resolve({ id, title: 'delectus aut autem ' + id, completed: false, userId: 1, }); export const getTodo = new Endpoint(getTodoOriginal);
import { getTodo } from './api'; function TodoDetail() { const todo = useSuspense(getTodo, 1); return <div>{todo.title}</div>; } render(<TodoDetail />);
Configuration sharing
Use Endpoint.extend() instead of {...getTodo}
(spread)
const getTodoNormalized = getTodo.extend({ schema: Todo });
const getTodoUpdatingEveryFiveSeconds = getTodo.extend({ pollFrequency: 5000 });
Lifecycle
Success
Error
Endpoint Members
Members double as options (second constructor arg). While none are required, the first few have defaults.
key: (params) => string
Serializes the parameters. This is used to build a lookup key in global stores.
Default:
`${this.name} ${JSON.stringify(params)}`;
When overriding key
, be sure to also include an updated testKey if
you intend on using that method.
testKey(key): boolean
Returns true
if the provided (fetch) key matches this endpoint.
This is used for mock interceptors with with <MockResolver />
name: string
Used in key to distinguish endpoints. Should be globally unique.
Defaults to this.fetch.name
This may break in production builds that change function names. This is often know as function name mangling.
In these cases you can override name
or disable function mangling.
sideEffect: boolean
Used to indicate endpoint might have side-effects (non-idempotent). This restricts it from being used with useSuspense() or useFetch() as those can hit the endpoint an unpredictable number of times.
schema: Schema
Declarative definition of how to process responses
- where to expect Entities
- Functions to deserialize fields
Not providing this option means no entities will be extracted.
import { Entity } from '@data-client/normalizr';
import { Endpoint } from '@data-client/endpoint';
class User extends Entity {
id = '';
username = '';
}
const getUser = new Endpoint(
({ id }) ⇒ fetch(`/users/${id}`),
{ schema: User }
);
dataExpiryLength?: number
Custom data cache lifetime for the fetched resource. Will override the value set in NetworkManager.
errorExpiryLength?: number
Custom data error lifetime for the fetched resource. Will override the value set in NetworkManager.
errorPolicy?: (error: any) => 'soft' | undefined
'soft' will use stale data (if exists) in case of error; undefined or not providing option will result in error.
errorPolicy(error) {
return error.status >= 500 ? 'soft' : undefined;
}
invalidIfStale: boolean
Indicates stale data should be considered unusable and thus not be returned from the cache. This means that useSuspense() will suspend when data is stale even if it already exists in cache.
pollFrequency: number
Frequency in millisecond to poll at. Requires using useSubscription() or useLive() to have an effect.
getOptimisticResponse: (snap, ...args) => expectedResponse
When provided, any fetches with this endpoint will behave as though the expectedResponse
return value
from this function was a succesful network response. When the actual fetch completes (regardless
of failure or success), the optimistic update will be replaced with the actual network response.
import { resource } from '@data-client/rest'; import { Post } from './Post'; export { Post }; export const PostResource = resource({ path: '/posts/:id', searchParams: {} as { userId?: string | number } | undefined, schema: Post, }).extend('vote', { path: '/posts/:id/vote', method: 'POST', body: undefined, schema: Post, getOptimisticResponse(snapshot, { id }) { const post = snapshot.get(Post, { id }); if (!post) throw snapshot.abort; return { id, votes: post.votes + 1, }; }, });
update()
(normalizedResponseOfThis, ...args) =>
({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) })
Try using Collections instead.
They are much easier to use and more robust!
type UpdateFunction<
Source extends EndpointInterface,
Updaters extends Record<string, any> = Record<string, any>,
> = (
source: ResultEntry<Source>,
...args: Parameters<Source>
) => { [K in keyof Updaters]: (result: Updaters[K]) => Updaters[K] };
Simplest case:
const createUser = new RestEndpoint({
path: '/user',
method: 'POST',
schema: User,
update: (newUserId: string) => ({
[userList.key()]: (users = []) => [newUserId, ...users],
}),
});
More updates:
const allusers = useSuspense(userList);
const adminUsers = useSuspense(userList, { admin: true });
The endpoint below ensures the new user shows up immediately in the usages above.
const createUser = new RestEndpoint({
path: '/user',
method: 'POST',
schema: User,
update: (newUserId, newUser) => {
const updates = {
[userList.key()]: (users = []) => [newUserId, ...users],
];
if (newUser.isAdmin) {
updates[userList.key({ admin: true })] = (users = []) => [newUserId, ...users];
}
return updates;
},
});
extend(options): Endpoint
Can be used to further customize the endpoint definition
const getUser = new Endpoint(({ id }) ⇒ fetch(`/users/${id}`));
const getUserNormalized = getUser.extend({ schema: User });
In addition to the members, fetch
can be sent to override the fetch function.
Examples
- Basic
- With Schema
- List
import { Endpoint } from '@data-client/endpoint';
const UserDetail = new Endpoint(
({ id }) ⇒ fetch(`/users/${id}`).then(res => res.json())
);
import { Endpoint } from '@data-client/endpoint';
import { Entity } from '@data-client/react';
class User extends Entity {
id = '';
username = '';
}
const UserDetail = new Endpoint(
({ id }) ⇒ fetch(`/users/${id}`).then(res => res.json()),
{ schema: User }
);
import { Endpoint } from '@data-client/endpoint';
import { Entity } from '@data-client/react';
class User extends Entity {
id = '';
username = '';
}
const UserList = new Endpoint(
() ⇒ fetch(`/users/`).then(res => res.json()),
{ schema: [User] }
);
- React
- JS/Node Schema
function UserProfile() {
const user = useSuspense(UserDetail, { id });
const ctrl = useController();
return <UserForm user={user} onSubmit={() => ctrl.fetch(UserDetail)} />;
}
const user = await UserDetail({ id: '5' });
console.log(user);
Additional
Motivation
There is a distinction between
- What are networking API is
- How to make a request, expected response fields, etc.
- How it is used
- Binding data, polling, triggering imperative fetch, etc.
Thus, there are many benefits to creating a distinct seperation of concerns between these two concepts.
With TypeScript Standard Endpoints
, we define a standard for declaring in
TypeScript the definition of a networking API.
- Allows API authors to publish npm packages containing their API interfaces
- Definitions can be consumed by any supporting library, allowing easy consumption across libraries like Vue, React, Angular
- Writing codegen pipelines becomes much easier as the output is minimal
- Product developers can use the definitions in a multitude of contexts where behaviors vary
- Product developers can easily share code across platforms with distinct behaviors needs like React Native and React Web
What's in an Endpoint
- A function that resolves the results
- A function to uniquely store those results
- Optional: information about how to store the data in a normalized cache
- Optional: whether the request could have side effects - to prevent repeat calls