diff --git a/.agents/skills/data-client-endpoint-setup/SKILL.md b/.agents/skills/data-client-endpoint-setup/SKILL.md index 10f2819c8fe2..189ebca2bc0e 100644 --- a/.agents/skills/data-client-endpoint-setup/SKILL.md +++ b/.agents/skills/data-client-endpoint-setup/SKILL.md @@ -6,7 +6,7 @@ disable-model-invocation: true # Custom Endpoint Setup -This skill configures `@data-client/endpoint` for wrapping existing async functions. It should be applied after `data-client-setup` detects custom async patterns that aren't REST or GraphQL. +This guide configures `@data-client/endpoint` for wrapping existing async functions. Use it once the Data Client provider is set up and the project has async operations that aren't REST or GraphQL. ## Installation diff --git a/.agents/skills/data-client-graphql-setup/SKILL.md b/.agents/skills/data-client-graphql-setup/SKILL.md index 3c6eb740d951..c9f071f6f8e5 100644 --- a/.agents/skills/data-client-graphql-setup/SKILL.md +++ b/.agents/skills/data-client-graphql-setup/SKILL.md @@ -6,7 +6,7 @@ disable-model-invocation: true # GraphQL Protocol Setup -This skill configures `@data-client/graphql` for a project. It should be applied after `data-client-setup` detects GraphQL patterns. +This guide configures `@data-client/graphql` for a project. Use it once the Data Client provider is set up and the project calls GraphQL APIs. ## Installation diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index 580cb4ae0114..8e044406ad1e 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -6,9 +6,9 @@ disable-model-invocation: true # REST Protocol Setup & Migration -This skill configures `@data-client/rest` for a project. It handles both fresh setup and migration from existing HTTP libraries. It should be applied after skill "data-client-setup" detects REST API patterns. +This guide configures `@data-client/rest` for a project. It handles both fresh setup and migration from existing HTTP libraries. Use it once the Data Client provider is set up and the project calls REST APIs. -**First, apply the skill "data-client-rest"** for accurate implementation patterns. +The [RestEndpoint](references/RestEndpoint.md) and [resource](references/resource.md) references cover the APIs this guide uses. If the skill "data-client-rest" is installed, also apply it for resource and endpoint patterns beyond setup. ## Step 1: Installation @@ -67,7 +67,7 @@ When multiple HTTP libraries are detected, run each sub-procedure on the relevan Each migration is a self-contained reference. Read only the relevant one(s) based on detection results above. After completing migrations, return here for base class setup. - **Axios** → [references/axios-migration.md](references/axios-migration.md) — codemod, interceptors, error handling, timeout, cancelToken, responseType, paramsSerializer, auth, validateStatus, CSRF, upload progress - - Run its codemod before any manual edits, from this skill's own copy: `npx jscodeshift -t /scripts/axios-to-rest.js --extensions=ts,tsx,js,jsx src/`. + - Run its codemod before any manual edits, from this skill's own copy at [scripts/axios-to-rest.js](scripts/axios-to-rest.js): `npx jscodeshift -t --extensions=ts,tsx,js,jsx src/`. - **Raw fetch** → [references/fetch-migration.md](references/fetch-migration.md) — fetch wrappers, headers, status checks, POST patterns, error handling - **Ky** → [references/ky-migration.md](references/ky-migration.md) — prefixUrl, hooks, HTTPError, instance config - **SuperAgent** → [references/superagent-migration.md](references/superagent-migration.md) — chained API, plugins, agents, file uploads diff --git a/.agents/skills/data-client-setup/SKILL.md b/.agents/skills/data-client-setup/SKILL.md index ace34730f0e9..840c71435ab5 100644 --- a/.agents/skills/data-client-setup/SKILL.md +++ b/.agents/skills/data-client-setup/SKILL.md @@ -1,6 +1,6 @@ --- name: data-client-setup -description: Install and set up @data-client/react or @data-client/vue in a project. Detects project type (NextJS, Expo, React Native, Vue, plain React) and protocol (REST, GraphQL, custom), then installs and hands off to the matching framework and protocol skills. +description: Install and set up @data-client/react or @data-client/vue in a project. Detects project type (NextJS, Expo, React Native, Vue, plain React) and protocol (REST, GraphQL, custom), then follows the bundled protocol-specific setup guide. disable-model-invocation: true --- @@ -60,16 +60,16 @@ For async operations that don't match REST or GraphQL: ### 4. Install the Skills This Project Needs -This skill hands off to other Data Client skills. Install the ones that match what you detected, skipping any already installed: +Protocol setup guides are bundled in this skill, but defining and using data afterwards is covered by other Data Client skills. Install the ones that match what you detected, skipping any already installed: | Detected | Skills | |----------|--------| | Always | `data-client-schema`, `data-client-manager` | | React (NextJS, Expo, React Native, plain React) | `data-client-react`, `data-client-react-testing` | | Vue | `data-client-vue`, `data-client-vue-testing` | -| REST | `data-client-rest-setup`, `data-client-rest` | -| GraphQL | `data-client-graphql-setup` | -| Custom async | `data-client-endpoint-setup` | +| REST | `data-client-rest` | +| GraphQL | None beyond Always (`data-client-schema` covers `@data-client/graphql`) | +| Custom async | None beyond Always (`data-client-schema` covers `@data-client/endpoint`) | Use the installer that installed this skill. For OpenSkills and the skills CLI, add `-g` if this skill lives under your home directory rather than the project. @@ -204,32 +204,32 @@ app.mount('#app'); ## Protocol-Specific Setup -After provider setup, apply the appropriate skill based on detected protocol: +After provider setup, follow the guide for each detected protocol. These guides are bundled copies of the standalone protocol setup skills, so they need nothing else installed. ### REST APIs -Apply skill **"data-client-rest-setup"** which will: +Follow [references/data-client-rest-setup.md](references/data-client-rest-setup.md), which will: 1. Install `@data-client/rest` 2. Offer to create a custom `BaseEndpoint` class extending `RestEndpoint` 3. Configure common behaviors: urlPrefix, authentication, error handling ### GraphQL APIs -Apply skill **"data-client-graphql-setup"** which will: +Follow [references/data-client-graphql-setup.md](references/data-client-graphql-setup.md), which will: 1. Install `@data-client/graphql` 2. Create and configure `GQLEndpoint` instance 3. Set up authentication headers ### Custom Async Operations -Apply skill **"data-client-endpoint-setup"** which will: +Follow [references/data-client-endpoint-setup.md](references/data-client-endpoint-setup.md), which will: 1. Install `@data-client/endpoint` 2. Offer to wrap existing async functions with `new Endpoint()` 3. Configure schemas and caching options ### Multiple Protocols -If multiple protocols are detected, apply multiple setup skills. Each protocol package can be installed alongside others. +If multiple protocols are detected, follow each protocol's guide. Each protocol package can be installed alongside others. ## Verification Checklist @@ -239,7 +239,7 @@ After setup, verify: - [ ] Provider/Plugin wraps the app at root level - [ ] Correct import path used (especially `@data-client/react/nextjs` for NextJS) - [ ] No duplicate providers in component tree -- [ ] Protocol-specific setup completed via appropriate skill +- [ ] Protocol-specific setup completed via its guide ## Common Issues @@ -277,3 +277,6 @@ For detailed API documentation, see the [references](references/) directory: - [DataClientPlugin](references/DataClientPlugin.md) - Plugin options (Vue) - [installation](references/installation.md) - Installation guide - [getDefaultManagers](references/getDefaultManagers.md) - Default managers +- [REST setup](references/data-client-rest-setup.md) - `@data-client/rest` setup and migration from axios, fetch, ky, superagent or got +- [GraphQL setup](references/data-client-graphql-setup.md) - `@data-client/graphql` setup +- [Custom endpoint setup](references/data-client-endpoint-setup.md) - Wrapping other async functions with `@data-client/endpoint` diff --git a/.agents/skills/data-client-setup/references.json b/.agents/skills/data-client-setup/references.json index 59ea6a6405d1..661f2a34b6e0 100644 --- a/.agents/skills/data-client-setup/references.json +++ b/.agents/skills/data-client-setup/references.json @@ -8,5 +8,10 @@ "DataProvider.md": "docs/core/api/DataProvider.md", "getDefaultManagers.md": "docs/core/api/getDefaultManagers.md", "installation.md": "docs/core/getting-started/installation.md" - } + }, + "skills": [ + "data-client-rest-setup", + "data-client-graphql-setup", + "data-client-endpoint-setup" + ] } diff --git a/.agents/skills/data-client-setup/references/data-client-endpoint-setup.md b/.agents/skills/data-client-setup/references/data-client-endpoint-setup.md new file mode 100644 index 000000000000..b5ad9b702f85 --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-endpoint-setup.md @@ -0,0 +1,337 @@ + + +# Custom Endpoint Setup + +This guide configures `@data-client/endpoint` for wrapping existing async functions. Use it once the Data Client provider is set up and the project has async operations that aren't REST or GraphQL. + +## Installation + +Install the endpoint package alongside the core package: + +```bash +# npm +npm install @data-client/endpoint + +# yarn +yarn add @data-client/endpoint + +# pnpm +pnpm add @data-client/endpoint +``` + +## When to Use + +Use `@data-client/endpoint` when: +- Working with third-party SDK clients (Firebase, Supabase, AWS SDK, etc.) +- Using WebSocket connections for data fetching +- Accessing local async storage (IndexedDB, AsyncStorage) +- Any async function that doesn't fit REST or GraphQL patterns + +## Wrapping Async Functions + +See [Endpoint](./data-client-endpoint-setup/references/Endpoint.md) for full API documentation. + +### Detection + +Scan for existing async functions that fetch data: +- Functions returning `Promise` +- SDK client methods +- WebSocket message handlers +- IndexedDB operations + +### Basic Wrapping Pattern + +**Before (existing code):** +```ts +// src/api/users.ts +export async function getUser(id: string): Promise { + const response = await sdk.users.get(id); + return response.data; +} + +export async function listUsers(filters: UserFilters): Promise { + const response = await sdk.users.list(filters); + return response.data; +} +``` + +**After (with Endpoint wrapper):** +```ts +// src/api/users.ts +import { Endpoint } from '@data-client/endpoint'; +import { User } from '../schemas/User'; + +// Original functions (keep for reference or direct use) +async function fetchUser(id: string): Promise { + const response = await sdk.users.get(id); + return response.data; +} + +async function fetchUsers(filters: UserFilters): Promise { + const response = await sdk.users.list(filters); + return response.data; +} + +// Wrapped as Endpoints for use with Data Client hooks +export const getUser = new Endpoint(fetchUser, { + schema: User, + name: 'getUser', +}); + +export const listUsers = new Endpoint(fetchUsers, { + schema: [User], + name: 'listUsers', +}); +``` + +## Endpoint Options + +Configure based on the function's behavior: + +```ts +export const getUser = new Endpoint(fetchUser, { + // Required for normalization + schema: User, + + // Unique name (important if function names get mangled in production) + name: 'getUser', + + // Mark as side-effect if it modifies data + sideEffect: true, // for mutations + + // Cache configuration + dataExpiryLength: 60000, // 1 minute + errorExpiryLength: 5000, // 5 seconds + + // Enable polling + pollFrequency: 30000, // poll every 30 seconds + + // Optimistic updates + getOptimisticResponse(snap, id) { + return snap.get(User, { id }); + }, +}); +``` + +## Custom Key Function + +If the default key function doesn't work for your use case: + +```ts +export const searchUsers = new Endpoint(fetchSearchUsers, { + schema: [User], + name: 'searchUsers', + key({ query, page }) { + // Custom key for complex parameters + return `searchUsers:${query}:${page}`; + }, +}); +``` + +## Common Patterns + +### Firebase/Firestore + +```ts +import { Endpoint } from '@data-client/endpoint'; +import { doc, getDoc, collection, getDocs } from 'firebase/firestore'; +import { db } from './firebase'; +import { User } from '../schemas/User'; + +async function fetchUser(id: string): Promise { + const docRef = doc(db, 'users', id); + const docSnap = await getDoc(docRef); + return { id: docSnap.id, ...docSnap.data() } as User; +} + +async function fetchUsers(): Promise { + const querySnapshot = await getDocs(collection(db, 'users')); + return querySnapshot.docs.map(doc => ({ + id: doc.id, + ...doc.data(), + })) as User[]; +} + +export const getUser = new Endpoint(fetchUser, { + schema: User, + name: 'getUser', +}); + +export const listUsers = new Endpoint(fetchUsers, { + schema: [User], + name: 'listUsers', +}); +``` + +### Supabase + +```ts +import { Endpoint } from '@data-client/endpoint'; +import { supabase } from './supabase'; +import { User } from '../schemas/User'; + +async function fetchUser(id: string): Promise { + const { data, error } = await supabase + .from('users') + .select('*') + .eq('id', id) + .single(); + if (error) throw error; + return data; +} + +async function fetchUsers(filters?: { role?: string }): Promise { + let query = supabase.from('users').select('*'); + if (filters?.role) { + query = query.eq('role', filters.role); + } + const { data, error } = await query; + if (error) throw error; + return data; +} + +export const getUser = new Endpoint(fetchUser, { + schema: User, + name: 'getUser', +}); + +export const listUsers = new Endpoint(fetchUsers, { + schema: [User], + name: 'listUsers', +}); +``` + +### IndexedDB + +```ts +import { Endpoint } from '@data-client/endpoint'; +import { User } from '../schemas/User'; + +async function fetchUserFromCache(id: string): Promise { + const db = await openDB('myapp', 1); + return db.get('users', id); +} + +async function fetchUsersFromCache(): Promise { + const db = await openDB('myapp', 1); + return db.getAll('users'); +} + +export const getCachedUser = new Endpoint(fetchUserFromCache, { + schema: User, + name: 'getCachedUser', + dataExpiryLength: Infinity, // Never expires +}); + +export const listCachedUsers = new Endpoint(fetchUsersFromCache, { + schema: [User], + name: 'listCachedUsers', + dataExpiryLength: Infinity, +}); +``` + +### WebSocket Fetch + +```ts +import { Endpoint } from '@data-client/endpoint'; +import { socket } from './socket'; +import { Message } from '../schemas/Message'; + +async function fetchMessages(roomId: string): Promise { + return new Promise((resolve, reject) => { + socket.emit('getMessages', { roomId }, (response: any) => { + if (response.error) reject(response.error); + else resolve(response.data); + }); + }); +} + +export const getMessages = new Endpoint(fetchMessages, { + schema: [Message], + name: 'getMessages', +}); +``` + +## Mutations with Side Effects + +```ts +export const createUser = new Endpoint( + async (userData: Omit): Promise => { + const { data, error } = await supabase + .from('users') + .insert(userData) + .select() + .single(); + if (error) throw error; + return data; + }, + { + schema: User, + name: 'createUser', + sideEffect: true, + }, +); + +export const deleteUser = new Endpoint( + async (id: string): Promise<{ id: string }> => { + const { error } = await supabase.from('users').delete().eq('id', id); + if (error) throw error; + return { id }; + }, + { + name: 'deleteUser', + sideEffect: true, + }, +); +``` + +## Using extend() for Variations + +```ts +const baseUserEndpoint = new Endpoint(fetchUser, { + schema: User, + name: 'getUser', +}); + +// With different cache settings +export const getUserFresh = baseUserEndpoint.extend({ + dataExpiryLength: 0, // Always refetch +}); + +// With polling +export const getUserLive = baseUserEndpoint.extend({ + pollFrequency: 5000, // Poll every 5 seconds +}); +``` + +## Important: Function Name Mangling + +In production builds, function names may be mangled. **Always provide explicit `name` option**: + +```ts +// Bad - name may become 'a' or similar in production +const getUser = new Endpoint(fetchUser); + +// Good - explicit name survives minification +const getUser = new Endpoint(fetchUser, { name: 'getUser' }); +``` + +## Usage in with hooks and controller + +```tsx +useSuspense(getUser, id); +ctrl.fetch(createUser, userData); +``` + +Both hooks and controller methods take endpoint as first argument, with the endpoint's function arguments following. + +## Next Steps + +1. Apply skill "data-client-schema" to define Entity classes +2. Apply skill "data-client-react" or "data-client-vue" for usage + +## References + +Vue projects: read `.vue.md` instead of `.md` when it exists. + +- [Endpoint](./data-client-endpoint-setup/references/Endpoint.md) - Full Endpoint API diff --git a/.agents/skills/data-client-setup/references/data-client-endpoint-setup/references/Endpoint.md b/.agents/skills/data-client-setup/references/data-client-endpoint-setup/references/Endpoint.md new file mode 100644 index 000000000000..6391efeab7ed --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-endpoint-setup/references/Endpoint.md @@ -0,0 +1,618 @@ + + +# 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](https://www.npmjs.com/package/@data-client/endpoint) + +> **Tip** +> +> Endpoint is a protocol independent class. Try using the protocol specific patterns +> [REST](https://dataclient.io/rest/api/RestEndpoint), [GraphQL](https://dataclient.io/graphql/api/GQLEndpoint), +> or [getImage](https://dataclient.io/docs/guides/img-media#just-images) instead. + +
+ +Interface + +**Interface** + +```typescript +export interface EndpointInterface< + F extends FetchFunction = FetchFunction, + S extends Schema | undefined = Schema | undefined, + M extends true | undefined = true | undefined, +> extends EndpointExtraOptions { + (...args: Parameters): InferReturn; + key(...args: Parameters): string; + readonly sideEffect?: M; + readonly schema?: S; +} +``` + +**Class** + +```typescript +class Endpoint Promise> + implements EndpointInterface +{ + constructor(fetchFunction: F, options: EndpointOptions); + + key(...args: Parameters): 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; +} +``` + +**EndpointExtraOptions** + +```typescript +export interface EndpointExtraOptions { + /** 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 milliseconds */ + 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 + ) => ResolveType; + /** 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. + +```ts title="interface" +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +} +``` + +```ts title="api" {12} +import { Endpoint } from '@data-client/rest'; +import { Todo } from './interface'; + +const getTodoOriginal = (id: number): Promise => + Promise.resolve({ + id, + title: 'delectus aut autem ' + id, + completed: false, + userId: 1, + }); + +export const getTodo = new Endpoint(getTodoOriginal); +``` + +```tsx title="React" +import { useSuspense } from '@data-client/react'; +import { getTodo } from './api'; + +function TodoDetail() { + const todo = useSuspense(getTodo, 1); + return
{todo.title}
; +} +render(); +``` + +### Configuration sharing + +Use [Endpoint.extend()](#extend) instead of `{...getTodo}` (spread) + +```ts +const getTodoNormalized = getTodo.extend({ schema: Todo }); +const getTodoUpdatingEveryFiveSeconds = getTodo.extend({ pollFrequency: 5000 }); +``` + +## Lifecycle + +### Success + +```mermaid +flowchart LR + subgraph Controller.fetch + direction TB + key("Endpoint.key(...args)")--->dispatch("dispatch(FETCH)") + end + subgraph managers + NetworkManager-->endpoint("endpoint(...args)") + endpoint--resolves-->Controller.resolve + Controller.resolve("Controller.resolve(response)")-->dispatchR("dispatch(SET_RESPONSE)") + end + managers--FETCH-->reducer:FETCH + Controller.fetch--FETCH-->managers + subgraph reducer:FETCH + optimistic("Endpoint.?getOptimisticResponse()")-->SET_RESPONSE + subgraph SET_RESPONSE + normalize(normalize)-->update("Endpoint.update()") + end + end + subgraph reducer:SET_RESPONSE + direction LR + normalize2(normalize)-->update2("Endpoint.update()") + end + managers--SET_RESPONSE-->reducer:SET_RESPONSE + click key "/rest/api/Endpoint#key" + click NetworkManager "/docs/api/NetworkManager" + click optimistic "/rest/api/Endpoint#getoptimisticresponse" + click update "/rest/api/Endpoint#update" + click update2 "/rest/api/Endpoint#update" + click dispatch "/docs/api/Actions#fetch" + click dispatchR "/docs/api/Actions#set_response" +``` + +### Error + +```mermaid +flowchart LR + subgraph Controller.fetch + direction TB + key("Endpoint.key(...args)")--->dispatch("dispatch(FETCH)") + end + subgraph managers + NetworkManager-->endpoint("endpoint(...args)") + endpoint--rejects-->Controller.resolve + Controller.resolve("Controller.resolve(error)")-->dispatchR("dispatch(SET_RESPONSE)") + end + managers--FETCH-->reducer:FETCH + Controller.fetch--FETCH-->managers + subgraph reducer:FETCH + optimistic("Endpoint.?getOptimisticResponse()")-->SET_RESPONSE + subgraph SET_RESPONSE + normalize(normalize)-->update("Endpoint.update()") + end + end + subgraph reducer:reduceError + direction LR + filterOptimistic(filterOptimistic)-->errorPolicy("Endpoint.errorPolicy()") + end + managers--SET_RESPONSE:error-->reducer:reduceError + click key "/rest/api/Endpoint#key" + click optimistic "/rest/api/Endpoint#getoptimisticresponse" + click update "/rest/api/Endpoint#update" + click errorPolicy "/rest/api/Endpoint#errorpolicy" + click NetworkManager "/docs/api/NetworkManager" + click dispatch "/docs/api/Actions#fetch" + click dispatchR "/docs/api/Actions#set_response" +``` + +## Endpoint Members + +Members double as options (second constructor arg). While none are required, the first few +have defaults. + +### key: (params) => string {#key} + +Serializes the parameters. This is used to build a lookup key in global stores. + +Default: + +```typescript +`${this.name} ${JSON.stringify(params)}`; +``` + +> **Warning: Overrides** +> +> When overriding `key`, be sure to also include an updated [testKey](#testKey) if +> you intend on using that method. + +### testKey(key): boolean {#testKey} + +Returns `true` if the provided (fetch) [key](#key) matches this endpoint. + +This is used for mock interceptors with with [\](https://dataclient.io/docs/api/MockResolver) + +### name: string {#name} + +Used in [key](#key) to distinguish endpoints. Should be globally unique. + +Defaults to `this.fetch.name` + +> **Warning** +> +> This may break in production builds that change function names. +> This is often know as [function name mangling](https://terser.org/docs/api-reference#mangle-options). +> +> In these cases you can override `name` or disable function mangling. + +### sideEffect: boolean {#sideeffect} + +Used to indicate endpoint might have side-effects (non-idempotent). This restricts it +from being used with [useSuspense()](https://dataclient.io/docs/api/useSuspense) or [useFetch()](https://dataclient.io/docs/api/useFetch) as those can hit the +endpoint an unpredictable number of times. + +### schema: Schema {#schema} + +Declarative definition of how to [process responses](https://dataclient.io/rest/api/schema) + +- [where](https://dataclient.io/rest/api/schema) to expect [Entities](https://dataclient.io/rest/api/Entity) +- Functions to [deserialize fields](https://dataclient.io/rest/guides/network-transform#deserializing-fields) + +Not providing this option means no entities will be extracted. + +```tsx +import { Endpoint, Entity } from '@data-client/endpoint'; + +class User extends Entity { + id = ''; + username = ''; +} + +const getUser = new Endpoint( + ({ id }) => fetch(`/users/${id}`), + { schema: User } +); +``` + +### dataExpiryLength?: number {#dataexpirylength} + +Custom data cache lifetime for the fetched resource. Will override the value set in NetworkManager. + +[Learn more about expiry time](https://dataclient.io/docs/concepts/expiry-policy#expiry-time) + +### errorExpiryLength?: number {#errorexpirylength} + +Custom data error lifetime for the fetched resource. Will override the value set in NetworkManager. + +### errorPolicy?: (error: any) => 'soft' | undefined {#errorpolicy} + +'soft' will use stale data (if exists) in case of error; undefined or not providing option will result +in error. + +[Learn more about errorPolicy](https://dataclient.io/docs/concepts/error-policy) + +```ts +errorPolicy(error) { + return error.status >= 500 ? 'soft' : undefined; +} +``` + +### invalidIfStale: boolean {#invalidifstale} + +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 {#pollfrequency} + +Frequency in millisecond to poll at. Requires using [useSubscription()](https://dataclient.io/docs/api/useSubscription) or +[useLive()](https://dataclient.io/docs/api/useLive) to have an effect. + +### getOptimisticResponse: (snap, ...args) => expectedResponse {#getoptimisticresponse} + +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. + +```ts title="Post" +import { Entity, EntityMixin } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +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, + }; + }, +}); +``` + +```tsx title="PostItem" {7} +import { useController } from '@data-client/react'; +import { PostResource, type Post } from './PostResource'; + +export default function PostItem({ post }: Props) { + const ctrl = useController(); + const handleVote = () => { + ctrl.fetch(PostResource.vote, { id: post.id }); + }; + return ( +
+
+ + + {post.votes} + + +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +interface Props { + post: Post; +} +``` + +```tsx title="TotalVotes" {11} +import { Query } from '@data-client/rest'; +import { useQuery } from '@data-client/react'; +import { PostResource } from './PostResource'; + +const queryTotalVotes = new Query( + PostResource.getList.schema, + posts => posts.reduce((total, post) => total + post.votes, 0), +); + +export default function TotalVotes({ userId }: Props) { + const totalVotes = useQuery(queryTotalVotes, { userId }); + return ( +
+ {totalVotes} votes total +
+ ); +} +interface Props { + userId: number; +} +``` + +```tsx title="PostList" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostItem from './PostItem'; +import TotalVotes from './TotalVotes'; + +function PostList() { + const userId = 2; + const posts = useSuspense(PostResource.getList, { userId }); + return ( +
+ {posts.map(post => ( + + ))} + +
+ ); +} +render(); +``` + +[Optimistic update guide](https://dataclient.io/rest/guides/optimistic-updates) + +### update() {#update} + +```ts +(normalizedResponseOfThis, ...args) => + ({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) }) +``` + +> **Tip** +> +> Try using [Collections](https://dataclient.io/rest/api/Collection) instead. +> +> They are much easier to use and more robust! + +```ts title="UpdateType.ts" +type UpdateFunction< + Source extends EndpointInterface, + Updaters extends Record = Record, +> = ( + source: ResultEntry, + ...args: Parameters +) => { [K in keyof Updaters]: (result: Updaters[K]) => Updaters[K] }; +``` + +Simplest case: + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId: string) => ({ + [userList.key()]: (users = []) => [newUserId, ...users], + }), +}); +``` + +More updates: + +```typescript title="Component.tsx" +const allusers = useSuspense(userList); +const adminUsers = useSuspense(userList, { admin: true }); +``` + +The endpoint below ensures the new user shows up immediately in the usages above. + +```ts title="userEndpoint.ts" +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 {#extend} + +Can be used to further customize the endpoint definition + +```typescript +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** + +```typescript +import { Endpoint } from '@data-client/endpoint'; + +const UserDetail = new Endpoint( + ({ id }) => fetch(`/users/${id}`).then(res => res.json()) +); +``` + +**With Schema** + +```typescript +import { Endpoint, Entity } from '@data-client/endpoint'; + +class User extends Entity { + id = ''; + username = ''; +} + +const UserDetail = new Endpoint( + ({ id }) => fetch(`/users/${id}`).then(res => res.json()), + { schema: User } +); +``` + +**List** + +```typescript +import { Endpoint, Entity } from '@data-client/endpoint'; + +class User extends Entity { + id = ''; + username = ''; +} + +const UserList = new Endpoint( + () => fetch(`/users/`).then(res => res.json()), + { schema: [User] } +); +``` + +**React** + +```tsx +import { useSuspense, useController } from '@data-client/react'; +import { UserDetail } from './api/User'; +import UserForm from './UserForm'; + +function UserProfile({ id }: { id: string }) { + const user = useSuspense(UserDetail, { id }); + const ctrl = useController(); + + return ctrl.fetch(UserDetail)} />; +} +``` + +**JS/Node Schema** + +```typescript +const user = await UserDetail({ id: '5' }); +console.log(user); +``` + +### Additional + +- [Pagination](https://dataclient.io/rest/guides/pagination) +- [Mocking unfinished endpoints](https://dataclient.io/rest/guides/mocking-unfinished) +- [Optimistic updates](https://dataclient.io/rest/guides/optimistic-updates) + +## 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 diff --git a/.agents/skills/data-client-setup/references/data-client-endpoint-setup/references/Endpoint.vue.md b/.agents/skills/data-client-setup/references/data-client-endpoint-setup/references/Endpoint.vue.md new file mode 100644 index 000000000000..1ed64339f589 --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-endpoint-setup/references/Endpoint.vue.md @@ -0,0 +1,621 @@ + + +# 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](https://www.npmjs.com/package/@data-client/endpoint) + +> **Tip** +> +> Endpoint is a protocol independent class. Try using the protocol specific patterns +> [REST](https://dataclient.io/rest/api/RestEndpoint), [GraphQL](https://dataclient.io/graphql/api/GQLEndpoint), +> or [getImage](https://dataclient.io/docs/guides/img-media#just-images) instead. + +
+ +Interface + +**Interface** + +```typescript +export interface EndpointInterface< + F extends FetchFunction = FetchFunction, + S extends Schema | undefined = Schema | undefined, + M extends true | undefined = true | undefined, +> extends EndpointExtraOptions { + (...args: Parameters): InferReturn; + key(...args: Parameters): string; + readonly sideEffect?: M; + readonly schema?: S; +} +``` + +**Class** + +```typescript +class Endpoint Promise> + implements EndpointInterface +{ + constructor(fetchFunction: F, options: EndpointOptions); + + key(...args: Parameters): 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; +} +``` + +**EndpointExtraOptions** + +```typescript +export interface EndpointExtraOptions { + /** 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 milliseconds */ + 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 + ) => ResolveType; + /** 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. + +```ts title="interface" +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +} +``` + +```ts title="api" {12} +import { Endpoint } from '@data-client/rest'; +import { Todo } from './interface'; + +const getTodoOriginal = (id: number): Promise => + Promise.resolve({ + id, + title: 'delectus aut autem ' + id, + completed: false, + userId: 1, + }); + +export const getTodo = new Endpoint(getTodoOriginal); +``` + +```tsx title="React" +import { useSuspense } from '@data-client/react'; +import { getTodo } from './api'; + +function TodoDetail() { + const todo = useSuspense(getTodo, 1); + return
{todo.title}
; +} +render(); +``` + +### Configuration sharing + +Use [Endpoint.extend()](#extend) instead of `{...getTodo}` (spread) + +```ts +const getTodoNormalized = getTodo.extend({ schema: Todo }); +const getTodoUpdatingEveryFiveSeconds = getTodo.extend({ pollFrequency: 5000 }); +``` + +## Lifecycle + +### Success + +```mermaid +flowchart LR + subgraph Controller.fetch + direction TB + key("Endpoint.key(...args)")--->dispatch("dispatch(FETCH)") + end + subgraph managers + NetworkManager-->endpoint("endpoint(...args)") + endpoint--resolves-->Controller.resolve + Controller.resolve("Controller.resolve(response)")-->dispatchR("dispatch(SET_RESPONSE)") + end + managers--FETCH-->reducer:FETCH + Controller.fetch--FETCH-->managers + subgraph reducer:FETCH + optimistic("Endpoint.?getOptimisticResponse()")-->SET_RESPONSE + subgraph SET_RESPONSE + normalize(normalize)-->update("Endpoint.update()") + end + end + subgraph reducer:SET_RESPONSE + direction LR + normalize2(normalize)-->update2("Endpoint.update()") + end + managers--SET_RESPONSE-->reducer:SET_RESPONSE + click key "/rest/api/Endpoint#key" + click NetworkManager "/docs/api/NetworkManager" + click optimistic "/rest/api/Endpoint#getoptimisticresponse" + click update "/rest/api/Endpoint#update" + click update2 "/rest/api/Endpoint#update" + click dispatch "/docs/api/Actions#fetch" + click dispatchR "/docs/api/Actions#set_response" +``` + +### Error + +```mermaid +flowchart LR + subgraph Controller.fetch + direction TB + key("Endpoint.key(...args)")--->dispatch("dispatch(FETCH)") + end + subgraph managers + NetworkManager-->endpoint("endpoint(...args)") + endpoint--rejects-->Controller.resolve + Controller.resolve("Controller.resolve(error)")-->dispatchR("dispatch(SET_RESPONSE)") + end + managers--FETCH-->reducer:FETCH + Controller.fetch--FETCH-->managers + subgraph reducer:FETCH + optimistic("Endpoint.?getOptimisticResponse()")-->SET_RESPONSE + subgraph SET_RESPONSE + normalize(normalize)-->update("Endpoint.update()") + end + end + subgraph reducer:reduceError + direction LR + filterOptimistic(filterOptimistic)-->errorPolicy("Endpoint.errorPolicy()") + end + managers--SET_RESPONSE:error-->reducer:reduceError + click key "/rest/api/Endpoint#key" + click optimistic "/rest/api/Endpoint#getoptimisticresponse" + click update "/rest/api/Endpoint#update" + click errorPolicy "/rest/api/Endpoint#errorpolicy" + click NetworkManager "/docs/api/NetworkManager" + click dispatch "/docs/api/Actions#fetch" + click dispatchR "/docs/api/Actions#set_response" +``` + +## Endpoint Members + +Members double as options (second constructor arg). While none are required, the first few +have defaults. + +### key: (params) => string {#key} + +Serializes the parameters. This is used to build a lookup key in global stores. + +Default: + +```typescript +`${this.name} ${JSON.stringify(params)}`; +``` + +> **Warning: Overrides** +> +> When overriding `key`, be sure to also include an updated [testKey](#testKey) if +> you intend on using that method. + +### testKey(key): boolean {#testKey} + +Returns `true` if the provided (fetch) [key](#key) matches this endpoint. + +This is used for mock interceptors with with [\](https://dataclient.io/docs/api/MockResolver) + +### name: string {#name} + +Used in [key](#key) to distinguish endpoints. Should be globally unique. + +Defaults to `this.fetch.name` + +> **Warning** +> +> This may break in production builds that change function names. +> This is often know as [function name mangling](https://terser.org/docs/api-reference#mangle-options). +> +> In these cases you can override `name` or disable function mangling. + +### sideEffect: boolean {#sideeffect} + +Used to indicate endpoint might have side-effects (non-idempotent). This restricts it +from being used with [useSuspense()](https://dataclient.io/vue/api/useSuspense) or [useFetch()](https://dataclient.io/vue/api/useFetch) as those can hit the +endpoint an unpredictable number of times. + +### schema: Schema {#schema} + +Declarative definition of how to [process responses](https://dataclient.io/rest/api/schema) + +- [where](https://dataclient.io/rest/api/schema) to expect [Entities](https://dataclient.io/rest/api/Entity) +- Functions to [deserialize fields](https://dataclient.io/rest/guides/network-transform#deserializing-fields) + +Not providing this option means no entities will be extracted. + +```tsx +import { Endpoint, Entity } from '@data-client/endpoint'; + +class User extends Entity { + id = ''; + username = ''; +} + +const getUser = new Endpoint( + ({ id }) => fetch(`/users/${id}`), + { schema: User } +); +``` + +### dataExpiryLength?: number {#dataexpirylength} + +Custom data cache lifetime for the fetched resource. Will override the value set in NetworkManager. + +[Learn more about expiry time](https://dataclient.io/vue/concepts/expiry-policy#expiry-time) + +### errorExpiryLength?: number {#errorexpirylength} + +Custom data error lifetime for the fetched resource. Will override the value set in NetworkManager. + +### errorPolicy?: (error: any) => 'soft' | undefined {#errorpolicy} + +'soft' will use stale data (if exists) in case of error; undefined or not providing option will result +in error. + +[Learn more about errorPolicy](https://dataclient.io/vue/concepts/error-policy) + +```ts +errorPolicy(error) { + return error.status >= 500 ? 'soft' : undefined; +} +``` + +### invalidIfStale: boolean {#invalidifstale} + +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 {#pollfrequency} + +Frequency in millisecond to poll at. Requires using [useSubscription()](https://dataclient.io/vue/api/useSubscription) or +[useLive()](https://dataclient.io/vue/api/useLive) to have an effect. + +### getOptimisticResponse: (snap, ...args) => expectedResponse {#getoptimisticresponse} + +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. + +```ts title="Post" +import { Entity, EntityMixin } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +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, + }; + }, +}); +``` + +```html title="PostItem.vue" {9} + + + +``` + +```html title="TotalVotes.vue" {13} + + + +``` + +```html title="PostList.vue" + + + +``` + +[Optimistic update guide](https://dataclient.io/rest/guides/optimistic-updates) + +### update() {#update} + +```ts +(normalizedResponseOfThis, ...args) => + ({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) }) +``` + +> **Tip** +> +> Try using [Collections](https://dataclient.io/rest/api/Collection) instead. +> +> They are much easier to use and more robust! + +```ts title="UpdateType.ts" +type UpdateFunction< + Source extends EndpointInterface, + Updaters extends Record = Record, +> = ( + source: ResultEntry, + ...args: Parameters +) => { [K in keyof Updaters]: (result: Updaters[K]) => Updaters[K] }; +``` + +Simplest case: + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId: string) => ({ + [userList.key()]: (users = []) => [newUserId, ...users], + }), +}); +``` + +More updates: + +```html title="Component.vue" + +``` + +The endpoint below ensures the new user shows up immediately in the usages above. + +```ts title="userEndpoint.ts" +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 {#extend} + +Can be used to further customize the endpoint definition + +```typescript +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** + +```typescript +import { Endpoint } from '@data-client/endpoint'; + +const UserDetail = new Endpoint( + ({ id }) => fetch(`/users/${id}`).then(res => res.json()) +); +``` + +**With Schema** + +```typescript +import { Endpoint, Entity } from '@data-client/endpoint'; + +class User extends Entity { + id = ''; + username = ''; +} + +const UserDetail = new Endpoint( + ({ id }) => fetch(`/users/${id}`).then(res => res.json()), + { schema: User } +); +``` + +**List** + +```typescript +import { Endpoint, Entity } from '@data-client/endpoint'; + +class User extends Entity { + id = ''; + username = ''; +} + +const UserList = new Endpoint( + () => fetch(`/users/`).then(res => res.json()), + { schema: [User] } +); +``` + +**React** + +```tsx +import { useSuspense, useController } from '@data-client/react'; +import { UserDetail } from './api/User'; +import UserForm from './UserForm'; + +function UserProfile({ id }: { id: string }) { + const user = useSuspense(UserDetail, { id }); + const ctrl = useController(); + + return ctrl.fetch(UserDetail)} />; +} +``` + +**JS/Node Schema** + +```typescript +const user = await UserDetail({ id: '5' }); +console.log(user); +``` + +### Additional + +- [Pagination](https://dataclient.io/rest/guides/pagination) +- [Mocking unfinished endpoints](https://dataclient.io/rest/guides/mocking-unfinished) +- [Optimistic updates](https://dataclient.io/rest/guides/optimistic-updates) + +## 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 diff --git a/.agents/skills/data-client-setup/references/data-client-graphql-setup.md b/.agents/skills/data-client-setup/references/data-client-graphql-setup.md new file mode 100644 index 000000000000..815386271976 --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-graphql-setup.md @@ -0,0 +1,207 @@ + + +# GraphQL Protocol Setup + +This guide configures `@data-client/graphql` for a project. Use it once the Data Client provider is set up and the project calls GraphQL APIs. + +## Installation + +Install the GraphQL package alongside the core package: + +```bash +# npm +npm install @data-client/graphql + +# yarn +yarn add @data-client/graphql + +# pnpm +pnpm add @data-client/graphql +``` + +## GQLEndpoint Setup + +### Basic Configuration + +Create a file at `src/api/gql.ts` (or similar): + +```ts +import { GQLEndpoint } from '@data-client/graphql'; + +export const gql = new GQLEndpoint('/graphql'); +``` + +### Detection Checklist + +Scan the existing codebase for GraphQL patterns: + +1. **GraphQL endpoint URL**: Look for `/graphql` or custom paths +2. **Authentication**: Check for auth headers in existing GraphQL client setup +3. **Custom headers**: API keys, tenant IDs, etc. +4. **Error handling**: GraphQL error parsing patterns + +### With Authentication + +```ts +import { GQLEndpoint } from '@data-client/graphql'; + +export const gql = new GQLEndpoint('/graphql', { + getHeaders() { + const token = localStorage.getItem('authToken'); + return { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), + }; + }, +}); +``` + +### Async Authentication (token refresh) + +```ts +import { GQLEndpoint } from '@data-client/graphql'; + +export const gql = new GQLEndpoint('/graphql', { + async getHeaders() { + const token = await getValidToken(); + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }; + }, +}); +``` + +### Custom Error Handling + +```ts +import { GQLEndpoint } from '@data-client/graphql'; + +class CustomGQLEndpoint extends GQLEndpoint { + async fetchResponse(input: RequestInfo, init: RequestInit): Promise { + const response = await super.fetchResponse(input, init); + + // Handle GraphQL errors + if (response.errors?.length) { + const authError = response.errors.find( + e => e.extensions?.code === 'UNAUTHENTICATED' + ); + if (authError) { + window.dispatchEvent(new CustomEvent('auth:expired')); + } + } + + return response; + } +} + +export const gql = new CustomGQLEndpoint('/graphql'); +``` + +## Defining Queries and Mutations + +### Query Example + +```ts +import { gql } from './gql'; +import { User } from '../schemas/User'; + +export const getUser = gql.query( + (v: { id: string }) => ` + query GetUser($id: ID!) { + user(id: $id) { + id + name + email + } + } + `, + { schema: User }, +); +``` + +### Mutation Example + +```ts +import { gql } from './gql'; +import { User } from '../schemas/User'; + +export const updateUser = gql.mutation( + (v: { id: string; name: string }) => ` + mutation UpdateUser($id: ID!, $name: String!) { + updateUser(id: $id, name: $name) { + id + name + } + } + `, + { schema: User }, +); +``` + +### With Collection + +```ts +import { gql } from './gql'; +import { User, UserCollection } from '../schemas/User'; + +export const listUsers = gql.query( + () => ` + query ListUsers { + users { + id + name + email + } + } + `, + { schema: UserCollection }, +); + +export const createUser = gql.mutation( + (v: { name: string; email: string }) => ` + mutation CreateUser($name: String!, $email: String!) { + createUser(name: $name, email: $email) { + id + name + email + } + } + `, + { schema: UserCollection.push }, +); +``` + +## Usage in Components + +```tsx +import { useSuspense, useController } from '@data-client/react'; +import { getUser, updateUser } from './api/users'; + +function UserProfile({ id }: { id: string }) { + const user = useSuspense(getUser, { id }); + const ctrl = useController(); + + const handleUpdate = async (name: string) => { + await ctrl.fetch(updateUser, { id, name }); + }; + + return ( +
+

{user.name}

+ +
+ ); +} +``` + +## Next Steps + +1. Apply skill "data-client-schema" to define Entity classes +2. Apply skill "data-client-react" or "data-client-vue" for usage + +## References + +- [GQLEndpoint](https://dataclient.io/graphql/api/GQLEndpoint) - Full GQLEndpoint API +- [GraphQL Guide](https://dataclient.io/graphql) - GraphQL usage guide +- [Authentication Guide](./data-client-graphql-setup/references/auth.md) - Auth patterns for GraphQL diff --git a/.agents/skills/data-client-setup/references/data-client-graphql-setup/references/auth.md b/.agents/skills/data-client-setup/references/data-client-graphql-setup/references/auth.md new file mode 100644 index 000000000000..4457f20969d1 --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-graphql-setup/references/auth.md @@ -0,0 +1,61 @@ + + +# GraphQL Authentication + +## Cookie Auth + +Here's an example using simple cookie auth: + +```ts title="schema/endpoint.ts" +export const gql = new GQLEndpoint('https://nosy-baritone.glitch.me', { + getRequestInit(body: any): Promise { + return { + ...super.getRequestInit(body), + credentials: 'same-origin', + }; + } +}); +export default gql; +``` + +## Access Tokens + +Here we'll use a member variable to track the access token and send it +in a header. + +```ts title="schema/endpoint.ts" +export const gql = new GQLEndpoint('https://nosy-baritone.glitch.me', { + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': this.accessToken, + }; + }, +}); +export default gql; +``` + +Then be sure to set the access token upon login: + +```ts +import gql from 'schema/endpoint'; + +function Auth() { + const handleLogin = useCallback( + async e => { + const { accessToken } = await login(new FormData(e.target)); + // success! + gql.accessToken = accessToken; + }, + [login], + ); + + return ; +} +``` + +## 401 Logout Handling + +In case a users authorization expires, the server will typically responsd to indicate +as such. The standard way of doing this is with a 401. [LogoutManager](https://dataclient.io/docs/api/LogoutManager) +can be used to easily trigger any de-authorization cleanup. diff --git a/.agents/skills/data-client-setup/references/data-client-rest-setup.md b/.agents/skills/data-client-setup/references/data-client-rest-setup.md new file mode 100644 index 000000000000..da3cb6e6d718 --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-rest-setup.md @@ -0,0 +1,305 @@ + + +# REST Protocol Setup & Migration + +This guide configures `@data-client/rest` for a project. It handles both fresh setup and migration from existing HTTP libraries. Use it once the Data Client provider is set up and the project calls REST APIs. + +The [RestEndpoint](./data-client-rest-setup/references/RestEndpoint.md) and [resource](./data-client-rest-setup/references/resource.md) references cover the APIs this guide uses. If the skill "data-client-rest" is installed, also apply it for resource and endpoint patterns beyond setup. + +## Step 1: Installation + +Install the REST package alongside the core package: + +```bash +# npm +npm install @data-client/rest + +# yarn +yarn add @data-client/rest + +# pnpm +pnpm add @data-client/rest +``` + +## Step 2: Detect Existing HTTP Patterns + +Scan the codebase to determine what's currently used. **Multiple patterns may coexist** — run each applicable migration sub-procedure independently on the relevant files. + +### Detection Checklist + +Check `package.json` dependencies and scan source files: + +| Check | Pattern | Action | +|-------|---------|--------| +| `"axios"` in dependencies, or `import.*from ['"]axios['"]` in source | **Axios** | Follow [references/axios-migration.md](./data-client-rest-setup/references/axios-migration.md) | +| `fetch(` calls with REST-style URLs, or wrapper functions around `fetch` | **Raw fetch** | Follow [references/fetch-migration.md](./data-client-rest-setup/references/fetch-migration.md) | +| `"ky"` in dependencies, or `import.*from ['"]ky['"]` | **Ky** | Follow [references/ky-migration.md](./data-client-rest-setup/references/ky-migration.md) | +| `"superagent"` in dependencies | **SuperAgent** | Follow [references/superagent-migration.md](./data-client-rest-setup/references/superagent-migration.md) | +| `"got"` in dependencies (rare in browser code) | **Got** | Follow [references/got-migration.md](./data-client-rest-setup/references/got-migration.md) | +| No existing HTTP library detected | **Fresh project** | Skip to [Step 3: Custom RestEndpoint Base Class](#step-3-custom-restendpoint-base-class) | + +### Ambiguous Detection + +If you cannot confidently determine which patterns are used (e.g., no clear imports but HTTP calls exist), **ask the user**: + +> I found HTTP calls in your codebase but couldn't determine the library. Are you migrating from: +> 1. axios +> 2. Raw fetch / custom fetch wrapper +> 3. ky +> 4. superagent +> 5. Something else (please describe) +> 6. Starting fresh (no migration needed) + +### Mixed Codebases + +When multiple HTTP libraries are detected, run each sub-procedure on the relevant files. The sub-procedures are independent and don't conflict: + +1. Identify which files use which library (group by import statements) +2. Run each applicable migration sub-procedure on its file group +3. After all migrations, proceed to the base class setup + +## Migration References + +Each migration is a self-contained reference. Read only the relevant one(s) based on detection results above. After completing migrations, return here for base class setup. + +- **Axios** → [references/axios-migration.md](./data-client-rest-setup/references/axios-migration.md) — codemod, interceptors, error handling, timeout, cancelToken, responseType, paramsSerializer, auth, validateStatus, CSRF, upload progress + - Run its codemod before any manual edits, from this skill's own copy at [scripts/axios-to-rest.js](./data-client-rest-setup/scripts/axios-to-rest.js): `npx jscodeshift -t --extensions=ts,tsx,js,jsx src/`. +- **Raw fetch** → [references/fetch-migration.md](./data-client-rest-setup/references/fetch-migration.md) — fetch wrappers, headers, status checks, POST patterns, error handling +- **Ky** → [references/ky-migration.md](./data-client-rest-setup/references/ky-migration.md) — prefixUrl, hooks, HTTPError, instance config +- **SuperAgent** → [references/superagent-migration.md](./data-client-rest-setup/references/superagent-migration.md) — chained API, plugins, agents, file uploads +- **Got** → [references/got-migration.md](./data-client-rest-setup/references/got-migration.md) — Node.js patterns, hooks, pagination, retry + +## Step 3: Custom RestEndpoint Base Class + +After installation and any migrations, **offer to create a custom RestEndpoint class** for the project. + +### Detection Checklist + +Scan the existing codebase for common REST patterns to include: + +1. **Base URL / API prefix**: Look for hardcoded URLs like `https://api.example.com` or env vars like `process.env.API_URL` +2. **Authentication**: Look for `Authorization` headers, tokens in localStorage/cookies, auth interceptors +3. **Content-Type handling**: Check if API uses JSON, form-data, or custom content types +4. **Error handling**: Look for error response patterns, status code handling +5. **Request/Response transforms**: Data transformations, date parsing, case conversion +6. **Query string format**: Simple params vs nested objects (may need qs library) + +### Base Class Template + +Create a file at `src/api/BaseEndpoint.ts` (or similar location based on project structure): + +```ts +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +/** + * Base RestEndpoint with project-specific defaults. + * Extend this for all REST API endpoints. + */ +export class BaseEndpoint extends RestEndpoint { + // API base URL - adjust based on detected patterns + urlPrefix = process.env.REACT_APP_API_URL ?? 'https://api.example.com'; + + // Add authentication headers + getHeaders(headers: HeadersInit): HeadersInit { + const token = localStorage.getItem('authToken'); + return { + ...headers, + ...(token && { Authorization: `Bearer ${token}` }), + }; + } +} +``` + +## Common Lifecycle Overrides + +Include these based on what's detected in the codebase. See [RestEndpoint](./data-client-rest-setup/references/RestEndpoint.md) for full API documentation. + +### Authentication (async token refresh) + +```ts +async getHeaders(headers: HeadersInit): Promise { + const token = await getValidToken(); // handles refresh + return { + ...headers, + Authorization: `Bearer ${token}`, + }; +} +``` + +### Authentication from React context (Okta, Auth0) + +When auth tokens live in React context (not localStorage), `getHeaders()` on a base class **cannot** access them. Use [`hookifyResource()`](./data-client-rest-setup/references/hookifyResource.md) to inject context-derived headers into every endpoint: + +```ts +import { hookifyResource, resource } from '@data-client/rest'; + +const ArticleResourceBase = resource({ + path: '/articles/:id', + schema: Article, + Endpoint: BaseEndpoint, +}); + +export const ArticleResource = hookifyResource( + ArticleResourceBase, + function useInit() { + const accessToken = useContext(AuthContext); + return { + headers: { Authorization: `Bearer ${accessToken}` }, + }; + }, +); +``` + +Usage: `useSuspense(ArticleResource.useGet(), { id })` — the hook calls `useInit()` on every render, so the token is always fresh from context. + +### Custom Request Init (CSRF, credentials) + +```ts +getRequestInit(body?: RequestInit['body'] | Record): RequestInit { + return { + ...super.getRequestInit(body), + credentials: 'include', // for cookies + headers: { + 'X-CSRF-Token': getCsrfToken(), + }, + }; +} +``` + +### Custom Response Parsing (unwrap data envelope) + +```ts +process(value: any, ...args: any[]) { + // If API wraps responses in { data: ... } + return value.data ?? value; +} +``` + +### Custom Error Handling + +```ts +async fetchResponse(input: RequestInfo, init: RequestInit): Promise { + const response = await super.fetchResponse(input, init); + + if (response.status === 401) { + window.dispatchEvent(new CustomEvent('auth:expired')); + } + + return response; +} +``` + +### Custom Search Params (using qs library) + +```ts +searchToString(searchParams: Record): string { + return qs.stringify(searchParams, { arrayFormat: 'brackets' }); +} +``` + +### Custom parseResponse (handle non-JSON) + +```ts +async parseResponse(response: Response): Promise { + const contentType = response.headers.get('content-type'); + + if (contentType?.includes('text/csv')) { + return parseCSV(await response.text()); + } + + return super.parseResponse(response); +} +``` + +## Full Example with Multiple Overrides + +```ts +import { RestEndpoint, RestGenerics } from '@data-client/rest'; +import qs from 'qs'; + +export class BaseEndpoint extends RestEndpoint { + urlPrefix = process.env.API_URL ?? 'http://localhost:3001/api'; + + async getHeaders(headers: HeadersInit): Promise { + const token = await getAuthToken(); + return { + ...headers, + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), + }; + } + + getRequestInit(body?: RequestInit['body'] | Record): RequestInit { + return { + ...super.getRequestInit(body), + credentials: 'include', + }; + } + + searchToString(searchParams: Record): string { + return qs.stringify(searchParams, { arrayFormat: 'brackets' }); + } + + process(value: any, ...args: any[]) { + return value?.data ?? value; + } +} + +async function getAuthToken(): Promise { + return localStorage.getItem('token'); +} +``` + +## Usage After Setup + +Once the base class is created, use it instead of [RestEndpoint](./data-client-rest-setup/references/RestEndpoint.md) directly. + +### Choosing `resource()` vs individual endpoints + +**Use `resource()`** when an API module has standard CRUD on a single path (list, get, create, update, delete). This is the common case: + +```ts +import { resource } from '@data-client/rest'; +import { BaseEndpoint } from './BaseEndpoint'; +import { Todo } from '../schemas/Todo'; + +export const TodoResource = resource({ + path: '/todos/:id', + schema: Todo, + Endpoint: BaseEndpoint, +}); +// Provides: TodoResource.get, .getList, .create, .update, .delete, .partialUpdate +``` + +**Use standalone `new BaseEndpoint()`** for non-CRUD operations (search, auth, custom actions) or when the path doesn't match `resource()` conventions: + +```ts +export const loginEndpoint = new BaseEndpoint({ + path: '/auth/login', + method: 'POST' as const, + body: {} as { email: string; password: string }, + schema: undefined, +}); +``` + +**Body typing**: Use `body: {} as BodyType` (truthy value) — not `undefined as unknown as BodyType`. The truthy value is needed so the endpoint correctly sends a request body for POST/PUT/PATCH. + +### Coexisting with existing validation (Zod, Yup) + +If the codebase already validates responses with Zod/Yup, prefer **Entity as the source of truth** for types that benefit from caching/normalization. Keep Zod only for types that don't need normalization (auth tokens, form validation types, one-off responses). See the migration reference files for detailed options. + +## Next Steps + +1. **Define Entity classes** (skill "data-client-schema") and **wire them to endpoints via `schema:`** — this is essential, not optional. Endpoints with `schema: undefined` bypass normalization and caching. +2. Apply skill "data-client-rest" for resource and endpoint patterns +3. Apply skill "data-client-react" or "data-client-vue" for hook-based usage + +## References + +Vue projects: read `.vue.md` instead of `.md` when it exists. + +- [RestEndpoint](./data-client-rest-setup/references/RestEndpoint.md) - Full RestEndpoint API +- [resource](./data-client-rest-setup/references/resource.md) - Resource factory function +- [Authentication Guide](./data-client-rest-setup/references/auth.md) - Auth patterns and examples +- [Django Integration](./data-client-rest-setup/references/django.md) - Django REST Framework patterns +- [Axios Migration Guide](https://dataclient.io/rest/guides/axios-migration) - Full axios migration documentation diff --git a/.agents/skills/data-client-setup/references/data-client-rest-setup/references/RestEndpoint.md b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/RestEndpoint.md new file mode 100644 index 000000000000..f60c6392eae9 --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/RestEndpoint.md @@ -0,0 +1,1486 @@ + + +# RestEndpoint + +`RestEndpoints` are for [HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP) based protocols like REST. + +> **Info: extends** +> +> `RestEndpoint` extends [Endpoint](https://dataclient.io/rest/api/Endpoint) + +
+ +Interface + +**RestEndpoint** + +```typescript +interface RestGenerics { + readonly path: string; + readonly schema?: Schema | undefined; + readonly method?: string; + readonly body?: any; + readonly searchParams?: any; + readonly paginationField?: string; + readonly content?: 'json' | 'blob' | 'text' | 'arrayBuffer' | 'stream'; + process?(value: any, ...args: any): any; +} + +export class RestEndpoint extends Endpoint { + /* Prepare fetch */ + readonly path: string; + readonly urlPrefix: string; + readonly requestInit: RequestInit; + readonly method: string; + readonly paginationField?: string; + readonly content?: 'json' | 'blob' | 'text' | 'arrayBuffer' | 'stream'; + readonly signal: AbortSignal | undefined; + url(...args: Parameters): string; + searchToString(searchParams: Record): string; + getRequestInit( + this: any, + body?: RequestInit['body'] | Record, + ): Promise | RequestInit; + getHeaders(headers: HeadersInit): Promise | HeadersInit; + + /* Perform/process fetch */ + fetchResponse(input: RequestInfo, init: RequestInit): Promise; + parseResponse(response: Response): Promise; + process(value: any, ...args: Parameters): any; + + testKey(key: string): boolean; +} +``` + +**Endpoint** + +```typescript +class Endpoint Promise> { + constructor(fetchFunction: F, options: EndpointOptions); + + key(...args: Parameters): string; + + readonly sideEffect?: true; + + readonly schema?: Schema; + + /** 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 milliseconds */ + 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 + ) => ResolveType; + /** Determines whether to throw or fallback to */ + readonly errorPolicy?: (error: any) => 'soft' | undefined; + + testKey(key: string): boolean; +} +``` + +
+ +## Usage + +All options are supported as arguments to the constructor, [extend](#extend), and as overrides when using [inheritance](#inheritance) + +### Simplest retrieval + +```ts +const getTodo = new RestEndpoint({ + path: '/todos/:id', +}); +``` + +```ts +const todo = await getTodo({ id: 1 }); +``` + +### Configuration sharing + +Use [RestEndpoint.extend()](#extend) instead of `{...getTodo}` ([Object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#spread_in_object_literals)) + +```ts +const updateTodo = getTodo.extend({ method: 'PUT' }); +``` + +### Managing state + +```ts path=Todo.ts +export class Todo extends Entity { + id = ''; + title = ''; + completed = false; +} + +export const getTodo = new RestEndpoint({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + schema: Todo, +}); +export const updateTodo = getTodo.extend({ method: 'PUT' }); +``` + +Using a [Schema](https://dataclient.io/rest/api/schema) enables [automatic data consistency](https://dataclient.io/docs/concepts/normalization) without the need to hurt performance with [refetching](https://dataclient.io/docs/api/Controller#expireAll). + +### Typing + +```ts title="Comment" +export class Comment extends Entity { + id = ''; + title = ''; + body = ''; + postId = ''; + + static key = 'Comment'; +} +``` + +```ts title="Usage" +import { Comment } from './Comment'; + +const getComments = new RestEndpoint({ + path: '/posts/:postId/comments', + schema: new Collection([Comment]), + searchParams: {} as { sortBy?: 'votes' | 'recent' } | undefined, +}); + +// Hover your mouse over 'comments' to see its type +const comments = useSuspense(getComments, { + postId: '5', + sortBy: 'votes', +}); + +const ctrl = useController(); +const createComment = async data => + ctrl.fetch(getComments.push, { postId: '5' }, data); +``` + +#### Resolution/Return + +[schema](#schema) determines the return value when used with data-binding hooks like [useSuspense](https://dataclient.io/docs/api/useSuspense), [useDLE](https://dataclient.io/docs/api/useDLE), [useCache](https://dataclient.io/docs/api/useCache) +or when used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +```ts title="Todo.ts" +export class Todo extends Entity { + id = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} +``` + +```ts title="getTodo.ts" +import { Todo } from './Todo'; + +const getTodo = new RestEndpoint({ path: '/', schema: Todo }); +// Hover your mouse over 'todo' to see its type +const todo = useSuspense(getTodo); + +async () => { + const ctrl = useController(); + const todo2 = await ctrl.fetch(getTodo); +}; +``` + +[process](#process) determines the resolution value when the endpoint is called directly. For +`RestEndpoints` without a schema, it also determines the return type of [hooks](https://dataclient.io/docs/api/useSuspense) and [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch). + +```ts path="process.ts" +interface TodoInterface { + title: string; + completed: boolean; +} +const getTodo = new RestEndpoint({ + path: '/', + process(value): TodoInterface { + return value; + }, +}); +async () => { + // todo is TodoInterface + const todo = await getTodo(); + + const ctrl = useController(); + const todo2 = await ctrl.fetch(getTodo); +}; +``` + +#### Function Parameters + +[path](#path) used to construct the url determines the type of the first argument. If it has no patterns, +then the 'first' argument is skipped. + +```ts +const getRoot = new RestEndpoint({ path: '/' }); +getRoot(); +const getById = new RestEndpoint({ path: '/:id' }); +// both number and string types work as they are serialized into strings to construct the url +getById({ id: 5 }); +getById({ id: '5' }); +``` + +[method](#method) determines whether there is a second argument to be sent as the [body](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#body). + +```ts path=method.ts +export const update = new RestEndpoint({ + path: '/:id', + method: 'PUT', +}); +update({ id: 5 }, { title: 'updated', completed: true }); +``` + +However, this is typed as 'any' so it won't catch typos. + +[body](#body) can be used to type the argument after the url parameters. It is only used for typing so the +value sent does not matter. `undefined` value can be used to 'disable' the second argument. + +```ts path=body.ts +export const update = new RestEndpoint({ + path: '/:id', + method: 'PUT', + body: {} as TodoInterface, +}); +update({ id: 5 }, { title: 'updated', completed: true }); +// `undefined` disables 'body' argument +const rpc = new RestEndpoint({ + path: '/:id', + method: 'PUT', + body: undefined, +}); +rpc({ id: 5 }); +``` + +[searchParams](#searchParams) can be used in a similar way to `body` to specify types extra parameters, used +for the GET searchParams/queryParams in a [url()](#url). + +```ts +const getUsers = new RestEndpoint({ + path: '/:group/user/:id', + searchParams: {} as { isAdmin?: boolean; sort: 'asc' | 'desc' }, +}); +getUsers.url({ group: 'big', id: '5', sort: 'asc' }) === + '/big/user/5?sort=asc'; +getUsers.url({ + group: 'big', + id: '5', + sort: 'desc', + isAdmin: true, +}) === '/big/user/5?isAdmin=true&sort=desc'; +``` + +## Fetch Lifecycle + +RestEndpoint adds to Endpoint by providing customizations for a provided fetch method using +[inheritance](#inheritance) or [.extend()](#extend). + +```mermaid +flowchart TB + URL-->response + INIT-->response + subgraph Prepare Fetch + subgraph URL + direction BT + urlPrefix-->url("url(urlParams)") + path-->url + searchToString("searchToString()")-->url + searchParams-->searchToString("searchToString()") + end + subgraph INIT + direction BT + getHeaders("getHeaders()")-->reqinit("getRequestInit(body)") + method-->reqinit + signal-->reqinit + end + end + subgraph Perform Fetch + response("fetchResponse()")-->parse("parseResponse()") + parse-->process("process()") + end + click url "/rest/api/RestEndpoint#url" + click searchToString "/rest/api/RestEndpoint#searchToString" + click searchParams "/rest/api/RestEndpoint#searchParams" + click urlPrefix "/rest/api/RestEndpoint#urlPrefix" + click path "/rest/api/RestEndpoint#path" + click getHeaders "/rest/api/RestEndpoint#getHeaders" + click method "/rest/api/RestEndpoint#method" + click signal "https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal" + click reqinit "/rest/api/RestEndpoint#getRequestInit" + click response "/rest/api/RestEndpoint#fetchResponse" + click parse "/rest/api/RestEndpoint#parseResponse" + click process "/rest/api/RestEndpoint#process" +``` + +```ts title="fetch implementation for RestEndpoint" +function fetch(...args) { + const urlParams = this.#hasBody && args.length < 2 ? {} : args[0] || {}; + const body = this.#hasBody ? args[args.length - 1] : undefined; + return this.fetchResponse( + this.url(urlParams), + await this.getRequestInit(body), + ) + .then(response => this.parseResponse(response)) + .then(res => this.process(res, ...args)); +} +``` + +## Prepare Fetch + +Members double as options (second constructor arg). While none are required, the first few +have defaults. + +### url(params): string {#url} + +`urlPrefix` + `path template` + '?' + searchToString(`searchParams`) + +`url()` uses the `params` to fill in the [path template](#path). Any unused `params` members are then used +as [searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) (aka 'GET' params - the stuff after `?`). + +
+ +Implementation + +```typescript +import { getUrlBase, getUrlTokens } from '@data-client/rest'; + +url(urlParams = {}) { + const urlBase = getUrlBase(this.path)(urlParams); + const tokens = getUrlTokens(this.path); + const searchParams = {}; + Object.keys(urlParams).forEach(k => { + if (!tokens.has(k)) { + searchParams[k] = urlParams[k]; + } + }); + if (Object.keys(searchParams).length) { + return `${this.urlPrefix}${urlBase}?${this.searchToString(searchParams)}`; + } + return `${this.urlPrefix}${urlBase}`; +} +``` + +
+ +### searchToString(searchParams): string {#searchToString} + +Constructs the [searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) component of [url](#url). + +By default uses the standard [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) global. + +[searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) (aka queryParams) are sorted to maintain determinism. + +
+ +Implementation + +```typescript +searchToString(searchParams) { + const params = new URLSearchParams(searchParams); + params.sort(); + return params.toString(); +} +``` + +
+ +#### Using `qs` library + +To encode complex objects in the searchParams, you can use the [qs](https://github.com/ljharb/qs) library. + +```typescript +import { RestEndpoint, RestGenerics } from '@data-client/rest'; +import qs from 'qs'; + +class QSEndpoint extends RestEndpoint { + searchToString(searchParams) { + return qs.stringify(searchParams); + } +} +``` + +```typescript title="QSEndpoint" {7} +import { RestEndpoint, RestGenerics } from '@data-client/rest'; +import qs from 'qs'; + +export default class QSEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + searchToString(searchParams) { + return qs.stringify(searchParams); + } +} +``` + +```typescript title="getFoo" +import QSEndpoint from './QSEndpoint'; + +const getFoo = new QSEndpoint({ + path: '/foo', + searchParams: {} as { a: Record }, +}); + +getFoo({ a: { b: 'c' } }); +``` + +### path: string {#path} + +Uses [path-to-regexp v8](https://github.com/pillarjs/path-to-regexp) to build +urls using the parameters passed. This also informs the types so they are properly enforced. + +#### Parameters + +`:` prefixed words are parameter names. Both strings and numbers are accepted as values, +since they are serialized into the url string. + +```ts +const getThing = new RestEndpoint({ path: '/:group/things/:id' }); +getThing({ group: 'first', id: 77 }); +``` + +#### Optional parameters + +Wrap the optional segment (including its prefix) in `{}` to make it [optional](https://github.com/pillarjs/path-to-regexp?tab=readme-ov-file#optional). +The type of optional parameters becomes `string | number | undefined`. + +```ts +const optional = new RestEndpoint({ + path: '/:group/things{/:number}', +}); +optional({ group: 'first' }); +optional({ group: 'first', number: 'fifty' }); +``` + +Multiple optional segments can be chained with different prefixes: + +```ts +const ep = new RestEndpoint({ + path: '{/:attr1}{-:attr2}{-:attr3}', +}); + +ep({ attr1: 'hi' }); +ep({ attr2: 'hi' }); +ep({ attr1: 'hi', attr3: 'ho' }); +``` + +#### Wildcards (repeating parameters) + +`*name` matches one-or-more path segments. Wrap in `{}` to make it zero-or-more (optional). +Wildcard parameters are typed as `string[]` (arrays), since they represent multiple path segments. + +```ts +const files = new RestEndpoint({ path: '/files/*path' }); +files({ path: ['documents', 'reports', 'q4'] }); +// URL: /files/documents/reports/q4 + +const optionalFiles = new RestEndpoint({ path: '/files{/*path}' }); +optionalFiles({}); +// URL: /files +optionalFiles({ path: ['documents'] }); +// URL: /files/documents +``` + +#### Quoted parameter names + +Parameter names must be valid JavaScript identifiers. Names containing special characters +like `-` or `.` must be quoted with double quotes: + +```ts +const ep = new RestEndpoint({ path: '/:"with-dash"/:"my.param"' }); +ep({ 'with-dash': 'hello', 'my.param': 'world' }); +``` + +#### Escaping special characters + +Characters `{}()*:` and `\\` are special in path-to-regexp and must be escaped with `\\` when used as literals. + +```ts +const getSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', +}); +getSite({ slug: 'first' }); +``` + +`?` and `+` are **not** special in path-to-regexp v8 and do not need escaping. +This means query strings can be embedded in the path without escaping `?`: + +```ts +const search = new RestEndpoint({ + path: '/search?{q=:q}{&page=:page}', +}); +search({ q: 'test', page: 1 }); +// URL: /search?q=test&page=1 +``` + +> **Info** +> +> Types are inferred automatically from `path`. +> +> Additional parameters can be specified with [searchParams](#searchParams) +> and [body](#body). + +### searchParams {#searchParams} + +`searchParams` can be to specify types extra parameters, used for the GET searchParams/queryParams in a [url()](#url). + +The actual **value is not used** in any way - this only determines [typing](#typing). + +```typescript title="getFoo" +import { RestEndpoint } from '@data-client/rest'; + +const getReactSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', + searchParams: {} as { isReact: boolean }, +}); + +getReactSite({ slug: 'cool', isReact: true }); +``` + +### body {#body} + +`body` can be used to set a second argument for mutation endpoints. The actual **value is not +used** in any way - this only determines [typing](#typing). + +This is only used by endpoings with a method that uses body: 'POST', 'PUT', 'PATCH'. + +```ts {6} +import { RestEndpoint } from '@data-client/rest'; + +const updateSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', + method: 'POST', + body: {} as { url: string }, +}); + +updateSite({ slug: 'cool' }, { url: '/' }); +``` + +### paginationField + +If specified, will add [getPage](#getpage) method on the `RestEndpoint`. [Pagination guide](https://dataclient.io/rest/guides/pagination). Schema +must also contain a [Collection](https://dataclient.io/rest/api/Collection). + +### urlPrefix: string = '' {#urlPrefix} + +Prepends this to the compiled [path](#path) + +#### Inheritance defaults + +```typescript +export class MyEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + // this allows us to override the prefix in production environments, with a dev fallback + urlPrefix = process.env.API_SERVER ?? 'http://localhost:8000'; +} +``` + +[Learn more about inheritance patterns](#inheritance) for RestEndpoint + +#### Instance overrides + +```typescript +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:product_id/ticker', + schema: Ticker, +}); +``` + +#### Dynamic prefix + +> **Tip** +> +> For a dynamic prefix, try overriding the url() method instead: +> +> ```ts +> const getTodo = new RestEndpoint({ +> path: '/todo/:id', +> url(...args) { +> return dynamicPrefix() + super.url(...args); +> }, +> }); +> ``` + +### method: string = 'GET' {#method} + +[Method](https://developer.mozilla.org/en-US/docs/Web/API/Request/method) is part of the HTTP protocol. +REST protocols use these to indicate the type of operation. Because of this RestEndpoint uses this +to inform `sideEffect` and whether the endpoint should use a `body` payload. Setting +`sideEffect` explicitly will override this behavior, allowing for non-standard API designs. + +`GET` is 'readonly', other methods imply sideEffects. + +`GET` and `DELETE` both default to no `body`. + +> **Tip: How method affects function Parameters** +> +> `method` only influences parameters in the RestEndpoint constructor and _not_ [.extend()](#extend). +> This allows non-standard method-body combinations. +> +> `body` will default to `any`. You can always set body explicitly to take full control. `undefined` can be used +> to indicate there is no body. +> +> ```ts +> (id: string, myPayload: Record) => { +> const standardCreate = new RestEndpoint({ +> path: '/:id', +> method: 'POST', +> }); +> standardCreate({ id }, myPayload); +> const nonStandardEndpoint = new RestEndpoint({ +> path: '/:id', +> method: 'POST', +> body: undefined, +> }); +> // no second 'body' argument, because body was set to 'undefined' +> nonStandardEndpoint({ id }); +> }; +> ``` + +### getRequestInit(body): RequestInit {#getRequestInit} + +Prepares [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) used in fetch. +This is sent to [fetchResponse](#fetchResponse) + +> **Tip: async** +> +> ```ts +> import { RestEndpoint, RestGenerics } from '@data-client/rest'; +> +> export default class AuthdEndpoint< +> O extends RestGenerics = any, +> > extends RestEndpoint { +> async getRequestInit(body) { +> return { +> ...(await super.getRequestInit(body)), +> method: await getMethod(), +> }; +> } +> } +> +> async function getMethod() { +> return 'GET'; +> } +> ``` + +### getHeaders(headers: HeadersInit): HeadersInit {#getHeaders} + +Called by [getRequestInit](#getRequestInit) to determine [HTTP Headers](https://developer.mozilla.org/en-US/docs/Web/API/Request/headers) + +This is often useful for [authentication](./auth.md) + +> **Warning** +> +> Don't use hooks here. If you need to use hooks, try using [hookifyResource](./hookifyResource.md) + +> **Tip: async** +> +> ```ts +> import { RestEndpoint, RestGenerics } from '@data-client/rest'; +> +> export default class AuthdEndpoint< +> O extends RestGenerics = any, +> > extends RestEndpoint { +> async getHeaders(headers: HeadersInit) { +> return { +> ...headers, +> 'Access-Token': await getAuthToken(), +> }; +> } +> } +> +> async function getAuthToken() { +> return 'example'; +> } +> ``` + +## Handle fetch + +### fetchResponse(input, init): Promise {#fetchResponse} + +Performs the [fetch(input, init)](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) call. When +[response.ok](https://developer.mozilla.org/en-US/docs/Web/API/Response/ok) is not `true` (like 404), +will throw a NetworkError. + +### content {#content} + +Controls how the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) body is parsed. +When set, the return type is inferred automatically, and `schema` is constrained to `undefined` +for non-JSON content types. + +| Value | Parses via | Return type | +| --------------- | ----------------------------------------------------------------------------------------------- | ---------------------------- | +| `'json'` | [response.json()](https://developer.mozilla.org/en-US/docs/Web/API/Response/json) | `any` | +| `'blob'` | [response.blob()](https://developer.mozilla.org/en-US/docs/Web/API/Response/blob) | `Blob` | +| `'text'` | [response.text()](https://developer.mozilla.org/en-US/docs/Web/API/Response/text) | `string` | +| `'arrayBuffer'` | [response.arrayBuffer()](https://developer.mozilla.org/en-US/docs/Web/API/Response/arrayBuffer) | `ArrayBuffer` | +| `'stream'` | `response.body` | `ReadableStream` | +| _unset_ | Auto-detect from Content-Type header | `any` | + +When `content` is not set, `parseResponse` auto-detects the response type from the +`Content-Type` header: JSON types call `.json()`, binary types (images, `application/octet-stream`, +PDFs, etc.) call `.blob()`, and text-like types call `.text()`. + +#### File downloads {#file-download} + +For file downloads, set `content: 'blob'`. The return type is `Blob` and `schema` must be +`undefined` (binary data cannot be normalized). Use `dataExpiryLength: 0` to avoid caching +large blobs in memory. + +```ts +const downloadFile = new RestEndpoint({ + path: '/files/:id/download', + content: 'blob', + dataExpiryLength: 0, +}); +``` + +To extract the filename from the `Content-Disposition` header, override `parseResponse`: + +```ts +const downloadFile = new RestEndpoint({ + path: '/files/:id/download', + content: 'blob', + dataExpiryLength: 0, + async parseResponse(response) { + const blob = await response.blob(); + const disposition = response.headers.get('Content-Disposition'); + const filename = + disposition?.match(/filename="?(.+?)"?$/)?.[1] ?? 'download'; + return { blob, filename }; + }, + process(value): { blob: Blob; filename: string } { + return value; + }, +}); +``` + +See [file download guide](https://dataclient.io/rest/guides/network-transform#file-download) for complete usage with browser download trigger. + +### parseResponse(response): Promise {#parseResponse} + +Takes the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) and parses the body. + +When [`content`](#content) is set, it controls parsing directly. Otherwise, auto-detection runs +based on the [`Content-Type` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type): +JSON types call [.json()](https://developer.mozilla.org/en-US/docs/Web/API/Response/json), binary +types call [.blob()](https://developer.mozilla.org/en-US/docs/Web/API/Response/blob), and text-like +types call [.text()](https://developer.mozilla.org/en-US/docs/Web/API/Response/text). + +If `status` is 204, resolves as `null`. + +Override this for advanced cases like extracting headers alongside the body. + +### process(value, ...args): any {#process} + +Perform any transforms with the parsed result. Defaults to identity function (do nothing). + +`args` are the arguments the endpoint was called with. They are typed from the endpoint's [path](#path), +[searchParams](#searchParams) and [body](#body), including those set in the same [extend()](#extend) call. + +```ts +const getUser = new RestEndpoint({ path: '/users/:id' }); + +const getUserWithId = getUser.extend({ + process(value, params) { + // params is { id: string | number } + return { ...value, id: `${params.id}` }; + }, +}); +``` + +> **Tip** +> +> The return type of process can be used to set the return type of the endpoint fetch: +> +> ```ts title="getTodo.ts" {4} +> export const getTodo = new RestEndpoint({ +> path: '/todos/:id', +> // The identity function is the default value; so we aren't changing any runtime behavior +> process(value): TodoInterface { +> return value; +> }, +> }); +> +> interface TodoInterface { +> id: string; +> title: string; +> completed: boolean; +> } +> ``` +> +> ```ts title="useTodo.ts" +> import { getTodo } from './getTodo'; +> +> async (id: string) => { +> // hover title to see it is a string +> // see TS autocomplete by deleting `.title` and retyping the `.` +> const title = (await getTodo({ id })).title; +> }; +> ``` + +## Endpoint Lifecycle + +### schema?: Schema {#schema} + +[Declarative data lifecycle](https://dataclient.io/rest/api/schema) + +- Global data consistency and performance with [DRY](https://www.plutora.com/blog/understanding-the-dry-dont-repeat-yourself-principle) state: [where](https://dataclient.io/rest/api/schema) to expect [Entities](https://dataclient.io/rest/api/Entity) +- Functions to [deserialize fields](https://dataclient.io/rest/guides/network-transform#deserializing-fields) +- [Race condition handling](https://dataclient.io/rest/api/Entity#shouldreorder) +- [Validation](https://dataclient.io/rest/api/Entity#validate) + +```tsx +import { Entity, RestEndpoint } from '@data-client/rest'; + +class User extends Entity { + id = ''; + username = ''; +} + +const getUser = new RestEndpoint({ + path: '/users/:id', + schema: User, +}); +``` + +### key(urlParams): string {#key} + +Serializes the parameters. This is used to build a lookup key in global stores. + +Default: + +```typescript +`${this.method} ${this.url(urlParams)}`; +``` + +### testKey(key): boolean {#testKey} + +Returns `true` if the provided (fetch) [key](#key) matches this endpoint. + +This is used for mock interceptors with with [\](https://dataclient.io/docs/api/MockResolver), +[Controller.expireAll()](https://dataclient.io/docs/api/Controller#expireAll), and [Controller.invalidateAll()](https://dataclient.io/docs/api/Controller#invalidateAll). + +### dataExpiryLength?: number {#dataexpirylength} + +Custom data cache lifetime for the fetched resource. Will override the value set in NetworkManager. + +[Learn more about expiry time](https://dataclient.io/docs/concepts/expiry-policy#expiry-time) + +### errorExpiryLength?: number {#errorexpirylength} + +Custom data error lifetime for the fetched resource. Will override the value set in NetworkManager. + +### errorPolicy?: (error: any) => 'soft' | undefined {#errorpolicy} + +'soft' will use stale data (if exists) in case of error; undefined or not providing option will result +in error. + +[Learn more about errorPolicy](https://dataclient.io/docs/concepts/error-policy) + +```ts +errorPolicy(error) { + return error.status >= 500 ? 'soft' : undefined; +} +``` + +### invalidIfStale: boolean {#invalidifstale} + +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 {#pollfrequency} + +Frequency in millisecond to poll at. Requires using [useSubscription()](https://dataclient.io/docs/api/useSubscription) or +[useLive()](https://dataclient.io/docs/api/useLive) to have an effect. + +### getOptimisticResponse: (snap, ...args) => expectedResponse {#getoptimisticresponse} + +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. + +```ts title="Post" +import { Entity, EntityMixin } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +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, + }; + }, +}); +``` + +```tsx title="PostItem" {7} +import { useController } from '@data-client/react'; +import { PostResource, type Post } from './PostResource'; + +export default function PostItem({ post }: Props) { + const ctrl = useController(); + const handleVote = () => { + ctrl.fetch(PostResource.vote, { id: post.id }); + }; + return ( +
+
+ + + {post.votes} + + +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +interface Props { + post: Post; +} +``` + +```tsx title="TotalVotes" {11} +import { Query } from '@data-client/rest'; +import { useQuery } from '@data-client/react'; +import { PostResource } from './PostResource'; + +const queryTotalVotes = new Query( + PostResource.getList.schema, + posts => posts.reduce((total, post) => total + post.votes, 0), +); + +export default function TotalVotes({ userId }: Props) { + const totalVotes = useQuery(queryTotalVotes, { userId }); + return ( +
+ {totalVotes} votes total +
+ ); +} +interface Props { + userId: number; +} +``` + +```tsx title="PostList" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostItem from './PostItem'; +import TotalVotes from './TotalVotes'; + +function PostList() { + const userId = 2; + const posts = useSuspense(PostResource.getList, { userId }); + return ( +
+ {posts.map(post => ( + + ))} + +
+ ); +} +render(); +``` + +[Optimistic update guide](https://dataclient.io/rest/guides/optimistic-updates) + +### update() {#update} + +```ts +(normalizedResponseOfThis, ...args) => + ({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) }) +``` + +> **Tip** +> +> Try using [Collections](https://dataclient.io/rest/api/Collection) instead. +> +> They are much easier to use and more robust! + +```ts title="UpdateType.ts" +type UpdateFunction< + Source extends EndpointInterface, + Updaters extends Record = Record, +> = ( + source: ResultEntry, + ...args: Parameters +) => { [K in keyof Updaters]: (result: Updaters[K]) => Updaters[K] }; +``` + +Simplest case: + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId: string) => ({ + [userList.key()]: (users = []) => [newUserId, ...users], + }), +}); +``` + +More updates: + +```typescript title="Component.tsx" +const allusers = useSuspense(userList); +const adminUsers = useSuspense(userList, { admin: true }); +``` + +The endpoint below ensures the new user shows up immediately in the usages above. + +```ts title="userEndpoint.ts" +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): RestEndpoint {#extend} + +Can be used to further customize the endpoint definition + +```typescript +const getUser = new RestEndpoint({ path: '/users/:id' }); + +const UserDetailNormalized = getUser.extend({ + schema: User, + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': getAuth(), + }; + }, +}); +``` + +## Specialized extenders + +These convenience accessors create new endpoints for common [Collection](https://dataclient.io/rest/api/Collection) operations. +They only work when the `RestEndpoint`'s schema contains a [Collection](https://dataclient.io/rest/api/Collection). + +### push + +Creates a POST endpoint that places newly created Entities at the _end_ of a [Collection](https://dataclient.io/rest/api/Collection). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.push](https://dataclient.io/rest/api/Collection#push) + +```tsx +import { RestEndpoint, Collection } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { Todo } from './resources'; + +const getTodos = new RestEndpoint({ + path: '/todos', + searchParams: {} as { userId?: string }, + schema: new Collection([Todo]), +}); +const ctrl = useController(); + +// POST /todos - adds new Todo to the end of the list +const newTodo = await ctrl.fetch( + getTodos.push, + { userId: '1' }, + { title: 'Buy groceries' }, +); +``` + +```tsx +import { resource } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { User } from './resources'; + +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); +const ctrl = useController(); + +// POST /groups/five/users - adds new User to the end of the list +const newUser = await ctrl.fetch( + UserResource.getList.push, + { group: 'five' }, + { username: 'newuser', email: 'new@example.com' }, +); +``` + +### unshift + +Creates a POST endpoint that places newly created Entities at the _start_ of a [Collection](https://dataclient.io/rest/api/Collection). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.unshift](https://dataclient.io/rest/api/Collection#unshift) + +```tsx +import { RestEndpoint, Collection } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { Todo } from './resources'; + +const getTodos = new RestEndpoint({ + path: '/todos', + searchParams: {} as { userId?: string }, + schema: new Collection([Todo]), +}); +const ctrl = useController(); + +// POST /todos - adds new Todo to the beginning of the list +const newTodo = await ctrl.fetch( + getTodos.unshift, + { userId: '1' }, + { title: 'Urgent task' }, +); +``` + +```tsx +import { resource } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { User } from './resources'; + +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); +const ctrl = useController(); + +// POST /groups/five/users - adds new User to the start of the list +const newUser = await ctrl.fetch( + UserResource.getList.unshift, + { group: 'five' }, + { username: 'priorityuser', email: 'priority@example.com' }, +); +``` + +### assign + +Creates a POST endpoint that merges Entities into a [Values](https://dataclient.io/rest/api/Values) [Collection](https://dataclient.io/rest/api/Collection). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.assign](https://dataclient.io/rest/api/Collection#assign) + +```tsx +import { RestEndpoint, Collection, Values } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { Stats } from './resources'; + +const getStats = new RestEndpoint({ + path: '/products/stats', + schema: new Collection(new Values(Stats)), +}); +const ctrl = useController(); + +// POST /products/stats - add/update entries in the Values collection +await ctrl.fetch(getStats.assign, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, + 'ETH-USD': { product_id: 'ETH-USD', volume: 500 }, +}); +``` + +```tsx +import { resource, Collection, Values } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { Stats } from './resources'; + +const StatsResource = resource({ + urlPrefix: 'https://api.exchange.example.com', + path: '/products/:product_id/stats', + schema: Stats, +}).extend({ + getList: { + path: '/products/stats', + schema: new Collection(new Values(Stats)), + }, +}); +const ctrl = useController(); + +// POST /products/stats - add/update entries +await ctrl.fetch(StatsResource.getList.assign, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, +}); +``` + +### remove + +Creates a PATCH endpoint that removes Entities from a [Collection](https://dataclient.io/rest/api/Collection) and updates them with the response. + +Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collection.remove](https://dataclient.io/rest/api/Collection#remove) + +```tsx +import { RestEndpoint, Collection } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { Todo } from './resources'; + +const getTodos = new RestEndpoint({ + path: '/todos', + schema: new Collection([Todo]), +}); +const ctrl = useController(); + +// PATCH /todos - removes Todo from collection AND updates the entity +await ctrl.fetch(getTodos.remove, { id: '123', completed: true }); +``` + +```tsx +import { resource } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { User } from './resources'; + +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); +const ctrl = useController(); + +// PATCH /groups/five/users - removes user from 'five' group list +// AND updates the user entity with response data (e.g., new group) +await ctrl.fetch( + UserResource.getList.remove, + { group: 'five' }, + { id: '2', group: 'newgroup' }, +); +``` + +To use the remove schema with a different endpoint (e.g., DELETE): + +```ts +const deleteAndRemove = MyResource.delete.extend({ + schema: MyResource.getList.schema.remove, +}); +``` + +### move + +Creates a PATCH endpoint that moves Entities between [Collections](https://dataclient.io/rest/api/Collection). It removes from +collections matching the entity's existing state and adds to collections matching the new values +(from the body/last arg). + +Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collection.move](https://dataclient.io/rest/api/Collection#move) + +```ts title="TaskResource" +import { Entity, resource } from '@data-client/rest'; + +export class Task extends Entity { + id = ''; + title = ''; + status = 'backlog'; + pk() { return this.id; } + static key = 'Task'; +} +export const TaskResource = resource({ + path: '/tasks/:id', + searchParams: {} as { status: string }, + schema: Task, + optimistic: true, +}); +``` + +```tsx title="TaskCard" {5-9} +import { useController } from '@data-client/react'; +import { TaskResource, type Task } from './TaskResource'; + +export default function TaskCard({ task }: { task: Task }) { + const handleMove = () => ctrl.fetch( + TaskResource.getList.move, + { id: task.id }, + { id: task.id, status: task.status === 'backlog' ? 'in-progress' : 'backlog' }, + ); + const ctrl = useController(); + return ( +
+ {task.title} + +
+ ); +} +``` + +```tsx title="TaskBoard" +import { useSuspense } from '@data-client/react'; +import { TaskResource } from './TaskResource'; +import TaskCard from './TaskCard'; + +function TaskBoard() { + const backlog = useSuspense(TaskResource.getList, { status: 'backlog' }); + const inProgress = useSuspense(TaskResource.getList, { status: 'in-progress' }); + return ( +
+
+

Backlog

+ {backlog.map(task => )} +
+
+

Active

+ {inProgress.map(task => )} +
+
+ ); +} +render(); +``` + +The remove filter is based on the entity's **existing** values in the store. +The add filter is based on the merged entity values (existing + body). +This uses the same [createCollectionFilter](https://dataclient.io/rest/api/Collection#createcollectionfilter) logic as push/remove. + +```tsx +import { resource } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { User } from './resources'; + +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); +const ctrl = useController(); + +// PATCH /groups/five/users/5 - moves user 5 from 'five' group to 'ten' group +await ctrl.fetch( + UserResource.getList.move, + { group: 'five', id: '2' }, + { id: '2', group: 'ten' }, +); +``` + +### getPage + +An endpoint to retrieve the next page using [paginationField](#paginationfield) as the searchParameter key. Schema +must also contain a [Collection](https://dataclient.io/rest/api/Collection) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + schema: Todo, + paginationField: 'page', +}); + +const todos = useSuspense(getTodos); +const ctrl = useController(); +return ( + + // fetches url `/todos?page=${nextPage}` + ctrl.fetch(getTodos.getPage, { page: nextPage }) + } + /> +); +``` + +See [pagination guide](https://dataclient.io/rest/guides/pagination) for more info. + +### paginated(paginationfield) {#paginated} + +Creates a new endpoint with an extra `paginationfield` string that will be used to find the specific +page, to append to this endpoint. See [Infinite Scrolling Pagination](https://dataclient.io/rest/guides/pagination#infinite-scrolling) for more info. + +```ts +const getNextPage = getList.paginated('cursor'); +``` + +Schema must also contain a [Collection](https://dataclient.io/rest/api/Collection) + +### paginated(removeCursor) {#paginated-function} + +```typescript +function paginated( + this: E, + removeCursor: (...args: A) => readonly [...Parameters], +): PaginationEndpoint; +``` + +The function form allows any argument processing. This is the equivalent of sending `cursor` string like above. + +```ts +const getNextPage = getList.paginated( + ({ cursor, ...rest }: { cursor: string | number }) => + (Object.keys(rest).length ? [rest] : []) as any, +); +``` + +`removeCusor` is a function that takes the arguments sent in fetch of `getNextPage` and returns +the arguments to update `getList`. + +Schema must also contain a [Collection](https://dataclient.io/rest/api/Collection) + +## Inheritance + +Make sure you use `RestGenerics` to keep types working. + +```ts +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; + +class GithubEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = 'https://api.github.com'; + + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': getAuth(), + }; + } +} +``` diff --git a/.agents/skills/data-client-setup/references/data-client-rest-setup/references/RestEndpoint.vue.md b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/RestEndpoint.vue.md new file mode 100644 index 000000000000..4ed3d6ac838c --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/RestEndpoint.vue.md @@ -0,0 +1,1511 @@ + + +# RestEndpoint + +`RestEndpoints` are for [HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP) based protocols like REST. + +> **Info: extends** +> +> `RestEndpoint` extends [Endpoint](https://dataclient.io/rest/api/Endpoint) + +
+ +Interface + +**RestEndpoint** + +```typescript +interface RestGenerics { + readonly path: string; + readonly schema?: Schema | undefined; + readonly method?: string; + readonly body?: any; + readonly searchParams?: any; + readonly paginationField?: string; + readonly content?: 'json' | 'blob' | 'text' | 'arrayBuffer' | 'stream'; + process?(value: any, ...args: any): any; +} + +export class RestEndpoint extends Endpoint { + /* Prepare fetch */ + readonly path: string; + readonly urlPrefix: string; + readonly requestInit: RequestInit; + readonly method: string; + readonly paginationField?: string; + readonly content?: 'json' | 'blob' | 'text' | 'arrayBuffer' | 'stream'; + readonly signal: AbortSignal | undefined; + url(...args: Parameters): string; + searchToString(searchParams: Record): string; + getRequestInit( + this: any, + body?: RequestInit['body'] | Record, + ): Promise | RequestInit; + getHeaders(headers: HeadersInit): Promise | HeadersInit; + + /* Perform/process fetch */ + fetchResponse(input: RequestInfo, init: RequestInit): Promise; + parseResponse(response: Response): Promise; + process(value: any, ...args: Parameters): any; + + testKey(key: string): boolean; +} +``` + +**Endpoint** + +```typescript +class Endpoint Promise> { + constructor(fetchFunction: F, options: EndpointOptions); + + key(...args: Parameters): string; + + readonly sideEffect?: true; + + readonly schema?: Schema; + + /** 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 milliseconds */ + 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 + ) => ResolveType; + /** Determines whether to throw or fallback to */ + readonly errorPolicy?: (error: any) => 'soft' | undefined; + + testKey(key: string): boolean; +} +``` + +
+ +## Usage + +All options are supported as arguments to the constructor, [extend](#extend), and as overrides when using [inheritance](#inheritance) + +### Simplest retrieval + +```ts +const getTodo = new RestEndpoint({ + path: '/todos/:id', +}); +``` + +```ts +const todo = await getTodo({ id: 1 }); +``` + +### Configuration sharing + +Use [RestEndpoint.extend()](#extend) instead of `{...getTodo}` ([Object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#spread_in_object_literals)) + +```ts +const updateTodo = getTodo.extend({ method: 'PUT' }); +``` + +### Managing state + +```ts path=Todo.ts +export class Todo extends Entity { + id = ''; + title = ''; + completed = false; +} + +export const getTodo = new RestEndpoint({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + schema: Todo, +}); +export const updateTodo = getTodo.extend({ method: 'PUT' }); +``` + +Using a [Schema](https://dataclient.io/rest/api/schema) enables [automatic data consistency](https://dataclient.io/vue/concepts/normalization) without the need to hurt performance with [refetching](https://dataclient.io/vue/api/Controller#expireAll). + +### Typing + +```ts title="Comment" +export class Comment extends Entity { + id = ''; + title = ''; + body = ''; + postId = ''; + + static key = 'Comment'; +} +``` + +```ts title="Usage" +import { Comment } from './Comment'; + +const getComments = new RestEndpoint({ + path: '/posts/:postId/comments', + schema: new Collection([Comment]), + searchParams: {} as { sortBy?: 'votes' | 'recent' } | undefined, +}); + +// Hover your mouse over 'comments' to see its type +const comments = await useSuspense(getComments, { + postId: '5', + sortBy: 'votes', +}); + +const ctrl = useController(); +const createComment = async data => + ctrl.fetch(getComments.push, { postId: '5' }, data); +``` + +#### Resolution/Return + +[schema](#schema) determines the return value when used with data-binding composables like [useSuspense](https://dataclient.io/vue/api/useSuspense), [useDLE](https://dataclient.io/vue/api/useDLE), [useCache](https://dataclient.io/vue/api/useCache) +or when used with [Controller.fetch](https://dataclient.io/vue/api/Controller#fetch) + +```ts title="Todo.ts" +export class Todo extends Entity { + id = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} +``` + +```ts title="getTodo.ts" +import { Todo } from './Todo'; + +const getTodo = new RestEndpoint({ path: '/', schema: Todo }); +// Hover your mouse over 'todo' to see its type +const todo = await useSuspense(getTodo); + +async () => { + const ctrl = useController(); + const todo2 = await ctrl.fetch(getTodo); +}; +``` + +[process](#process) determines the resolution value when the endpoint is called directly. For +`RestEndpoints` without a schema, it also determines the return type of [composables](https://dataclient.io/vue/api/useSuspense) and [Controller.fetch](https://dataclient.io/vue/api/Controller#fetch). + +```ts path="process.ts" +interface TodoInterface { + title: string; + completed: boolean; +} +const getTodo = new RestEndpoint({ + path: '/', + process(value): TodoInterface { + return value; + }, +}); +async () => { + // todo is TodoInterface + const todo = await getTodo(); + + const ctrl = useController(); + const todo2 = await ctrl.fetch(getTodo); +}; +``` + +#### Function Parameters + +[path](#path) used to construct the url determines the type of the first argument. If it has no patterns, +then the 'first' argument is skipped. + +```ts +const getRoot = new RestEndpoint({ path: '/' }); +getRoot(); +const getById = new RestEndpoint({ path: '/:id' }); +// both number and string types work as they are serialized into strings to construct the url +getById({ id: 5 }); +getById({ id: '5' }); +``` + +[method](#method) determines whether there is a second argument to be sent as the [body](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#body). + +```ts path=method.ts +export const update = new RestEndpoint({ + path: '/:id', + method: 'PUT', +}); +update({ id: 5 }, { title: 'updated', completed: true }); +``` + +However, this is typed as 'any' so it won't catch typos. + +[body](#body) can be used to type the argument after the url parameters. It is only used for typing so the +value sent does not matter. `undefined` value can be used to 'disable' the second argument. + +```ts path=body.ts +export const update = new RestEndpoint({ + path: '/:id', + method: 'PUT', + body: {} as TodoInterface, +}); +update({ id: 5 }, { title: 'updated', completed: true }); +// `undefined` disables 'body' argument +const rpc = new RestEndpoint({ + path: '/:id', + method: 'PUT', + body: undefined, +}); +rpc({ id: 5 }); +``` + +[searchParams](#searchParams) can be used in a similar way to `body` to specify types extra parameters, used +for the GET searchParams/queryParams in a [url()](#url). + +```ts +const getUsers = new RestEndpoint({ + path: '/:group/user/:id', + searchParams: {} as { isAdmin?: boolean; sort: 'asc' | 'desc' }, +}); +getUsers.url({ group: 'big', id: '5', sort: 'asc' }) === + '/big/user/5?sort=asc'; +getUsers.url({ + group: 'big', + id: '5', + sort: 'desc', + isAdmin: true, +}) === '/big/user/5?isAdmin=true&sort=desc'; +``` + +## Fetch Lifecycle + +RestEndpoint adds to Endpoint by providing customizations for a provided fetch method using +[inheritance](#inheritance) or [.extend()](#extend). + +```mermaid +flowchart TB + URL-->response + INIT-->response + subgraph Prepare Fetch + subgraph URL + direction BT + urlPrefix-->url("url(urlParams)") + path-->url + searchToString("searchToString()")-->url + searchParams-->searchToString("searchToString()") + end + subgraph INIT + direction BT + getHeaders("getHeaders()")-->reqinit("getRequestInit(body)") + method-->reqinit + signal-->reqinit + end + end + subgraph Perform Fetch + response("fetchResponse()")-->parse("parseResponse()") + parse-->process("process()") + end + click url "/rest/api/RestEndpoint#url" + click searchToString "/rest/api/RestEndpoint#searchToString" + click searchParams "/rest/api/RestEndpoint#searchParams" + click urlPrefix "/rest/api/RestEndpoint#urlPrefix" + click path "/rest/api/RestEndpoint#path" + click getHeaders "/rest/api/RestEndpoint#getHeaders" + click method "/rest/api/RestEndpoint#method" + click signal "https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal" + click reqinit "/rest/api/RestEndpoint#getRequestInit" + click response "/rest/api/RestEndpoint#fetchResponse" + click parse "/rest/api/RestEndpoint#parseResponse" + click process "/rest/api/RestEndpoint#process" +``` + +```ts title="fetch implementation for RestEndpoint" +function fetch(...args) { + const urlParams = this.#hasBody && args.length < 2 ? {} : args[0] || {}; + const body = this.#hasBody ? args[args.length - 1] : undefined; + return this.fetchResponse( + this.url(urlParams), + await this.getRequestInit(body), + ) + .then(response => this.parseResponse(response)) + .then(res => this.process(res, ...args)); +} +``` + +## Prepare Fetch + +Members double as options (second constructor arg). While none are required, the first few +have defaults. + +### url(params): string {#url} + +`urlPrefix` + `path template` + '?' + searchToString(`searchParams`) + +`url()` uses the `params` to fill in the [path template](#path). Any unused `params` members are then used +as [searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) (aka 'GET' params - the stuff after `?`). + +
+ +Implementation + +```typescript +import { getUrlBase, getUrlTokens } from '@data-client/rest'; + +url(urlParams = {}) { + const urlBase = getUrlBase(this.path)(urlParams); + const tokens = getUrlTokens(this.path); + const searchParams = {}; + Object.keys(urlParams).forEach(k => { + if (!tokens.has(k)) { + searchParams[k] = urlParams[k]; + } + }); + if (Object.keys(searchParams).length) { + return `${this.urlPrefix}${urlBase}?${this.searchToString(searchParams)}`; + } + return `${this.urlPrefix}${urlBase}`; +} +``` + +
+ +### searchToString(searchParams): string {#searchToString} + +Constructs the [searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) component of [url](#url). + +By default uses the standard [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) global. + +[searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) (aka queryParams) are sorted to maintain determinism. + +
+ +Implementation + +```typescript +searchToString(searchParams) { + const params = new URLSearchParams(searchParams); + params.sort(); + return params.toString(); +} +``` + +
+ +#### Using `qs` library + +To encode complex objects in the searchParams, you can use the [qs](https://github.com/ljharb/qs) library. + +```typescript +import { RestEndpoint, RestGenerics } from '@data-client/rest'; +import qs from 'qs'; + +class QSEndpoint extends RestEndpoint { + searchToString(searchParams) { + return qs.stringify(searchParams); + } +} +``` + +```typescript title="QSEndpoint" {7} +import { RestEndpoint, RestGenerics } from '@data-client/rest'; +import qs from 'qs'; + +export default class QSEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + searchToString(searchParams) { + return qs.stringify(searchParams); + } +} +``` + +```typescript title="getFoo" +import QSEndpoint from './QSEndpoint'; + +const getFoo = new QSEndpoint({ + path: '/foo', + searchParams: {} as { a: Record }, +}); + +getFoo({ a: { b: 'c' } }); +``` + +### path: string {#path} + +Uses [path-to-regexp v8](https://github.com/pillarjs/path-to-regexp) to build +urls using the parameters passed. This also informs the types so they are properly enforced. + +#### Parameters + +`:` prefixed words are parameter names. Both strings and numbers are accepted as values, +since they are serialized into the url string. + +```ts +const getThing = new RestEndpoint({ path: '/:group/things/:id' }); +getThing({ group: 'first', id: 77 }); +``` + +#### Optional parameters + +Wrap the optional segment (including its prefix) in `{}` to make it [optional](https://github.com/pillarjs/path-to-regexp?tab=readme-ov-file#optional). +The type of optional parameters becomes `string | number | undefined`. + +```ts +const optional = new RestEndpoint({ + path: '/:group/things{/:number}', +}); +optional({ group: 'first' }); +optional({ group: 'first', number: 'fifty' }); +``` + +Multiple optional segments can be chained with different prefixes: + +```ts +const ep = new RestEndpoint({ + path: '{/:attr1}{-:attr2}{-:attr3}', +}); + +ep({ attr1: 'hi' }); +ep({ attr2: 'hi' }); +ep({ attr1: 'hi', attr3: 'ho' }); +``` + +#### Wildcards (repeating parameters) + +`*name` matches one-or-more path segments. Wrap in `{}` to make it zero-or-more (optional). +Wildcard parameters are typed as `string[]` (arrays), since they represent multiple path segments. + +```ts +const files = new RestEndpoint({ path: '/files/*path' }); +files({ path: ['documents', 'reports', 'q4'] }); +// URL: /files/documents/reports/q4 + +const optionalFiles = new RestEndpoint({ path: '/files{/*path}' }); +optionalFiles({}); +// URL: /files +optionalFiles({ path: ['documents'] }); +// URL: /files/documents +``` + +#### Quoted parameter names + +Parameter names must be valid JavaScript identifiers. Names containing special characters +like `-` or `.` must be quoted with double quotes: + +```ts +const ep = new RestEndpoint({ path: '/:"with-dash"/:"my.param"' }); +ep({ 'with-dash': 'hello', 'my.param': 'world' }); +``` + +#### Escaping special characters + +Characters `{}()*:` and `\\` are special in path-to-regexp and must be escaped with `\\` when used as literals. + +```ts +const getSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', +}); +getSite({ slug: 'first' }); +``` + +`?` and `+` are **not** special in path-to-regexp v8 and do not need escaping. +This means query strings can be embedded in the path without escaping `?`: + +```ts +const search = new RestEndpoint({ + path: '/search?{q=:q}{&page=:page}', +}); +search({ q: 'test', page: 1 }); +// URL: /search?q=test&page=1 +``` + +> **Info** +> +> Types are inferred automatically from `path`. +> +> Additional parameters can be specified with [searchParams](#searchParams) +> and [body](#body). + +### searchParams {#searchParams} + +`searchParams` can be to specify types extra parameters, used for the GET searchParams/queryParams in a [url()](#url). + +The actual **value is not used** in any way - this only determines [typing](#typing). + +```typescript title="getFoo" +import { RestEndpoint } from '@data-client/rest'; + +const getReactSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', + searchParams: {} as { isReact: boolean }, +}); + +getReactSite({ slug: 'cool', isReact: true }); +``` + +### body {#body} + +`body` can be used to set a second argument for mutation endpoints. The actual **value is not +used** in any way - this only determines [typing](#typing). + +This is only used by endpoings with a method that uses body: 'POST', 'PUT', 'PATCH'. + +```ts {6} +import { RestEndpoint } from '@data-client/rest'; + +const updateSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', + method: 'POST', + body: {} as { url: string }, +}); + +updateSite({ slug: 'cool' }, { url: '/' }); +``` + +### paginationField + +If specified, will add [getPage](#getpage) method on the `RestEndpoint`. [Pagination guide](https://dataclient.io/rest/guides/pagination). Schema +must also contain a [Collection](https://dataclient.io/rest/api/Collection). + +### urlPrefix: string = '' {#urlPrefix} + +Prepends this to the compiled [path](#path) + +#### Inheritance defaults + +```typescript +export class MyEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + // this allows us to override the prefix in production environments, with a dev fallback + urlPrefix = process.env.API_SERVER ?? 'http://localhost:8000'; +} +``` + +[Learn more about inheritance patterns](#inheritance) for RestEndpoint + +#### Instance overrides + +```typescript +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:product_id/ticker', + schema: Ticker, +}); +``` + +#### Dynamic prefix + +> **Tip** +> +> For a dynamic prefix, try overriding the url() method instead: +> +> ```ts +> const getTodo = new RestEndpoint({ +> path: '/todo/:id', +> url(...args) { +> return dynamicPrefix() + super.url(...args); +> }, +> }); +> ``` + +### method: string = 'GET' {#method} + +[Method](https://developer.mozilla.org/en-US/docs/Web/API/Request/method) is part of the HTTP protocol. +REST protocols use these to indicate the type of operation. Because of this RestEndpoint uses this +to inform `sideEffect` and whether the endpoint should use a `body` payload. Setting +`sideEffect` explicitly will override this behavior, allowing for non-standard API designs. + +`GET` is 'readonly', other methods imply sideEffects. + +`GET` and `DELETE` both default to no `body`. + +> **Tip: How method affects function Parameters** +> +> `method` only influences parameters in the RestEndpoint constructor and _not_ [.extend()](#extend). +> This allows non-standard method-body combinations. +> +> `body` will default to `any`. You can always set body explicitly to take full control. `undefined` can be used +> to indicate there is no body. +> +> ```ts +> (id: string, myPayload: Record) => { +> const standardCreate = new RestEndpoint({ +> path: '/:id', +> method: 'POST', +> }); +> standardCreate({ id }, myPayload); +> const nonStandardEndpoint = new RestEndpoint({ +> path: '/:id', +> method: 'POST', +> body: undefined, +> }); +> // no second 'body' argument, because body was set to 'undefined' +> nonStandardEndpoint({ id }); +> }; +> ``` + +### getRequestInit(body): RequestInit {#getRequestInit} + +Prepares [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) used in fetch. +This is sent to [fetchResponse](#fetchResponse) + +> **Tip: async** +> +> ```ts +> import { RestEndpoint, RestGenerics } from '@data-client/rest'; +> +> export default class AuthdEndpoint< +> O extends RestGenerics = any, +> > extends RestEndpoint { +> async getRequestInit(body) { +> return { +> ...(await super.getRequestInit(body)), +> method: await getMethod(), +> }; +> } +> } +> +> async function getMethod() { +> return 'GET'; +> } +> ``` + +### getHeaders(headers: HeadersInit): HeadersInit {#getHeaders} + +Called by [getRequestInit](#getRequestInit) to determine [HTTP Headers](https://developer.mozilla.org/en-US/docs/Web/API/Request/headers) + +This is often useful for [authentication](./auth.vue.md) + +> **Warning** +> +> Don't use composables here. If you need to use composables, try using [hookifyResource](./hookifyResource.vue.md) + +> **Tip: async** +> +> ```ts +> import { RestEndpoint, RestGenerics } from '@data-client/rest'; +> +> export default class AuthdEndpoint< +> O extends RestGenerics = any, +> > extends RestEndpoint { +> async getHeaders(headers: HeadersInit) { +> return { +> ...headers, +> 'Access-Token': await getAuthToken(), +> }; +> } +> } +> +> async function getAuthToken() { +> return 'example'; +> } +> ``` + +## Handle fetch + +### fetchResponse(input, init): Promise {#fetchResponse} + +Performs the [fetch(input, init)](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) call. When +[response.ok](https://developer.mozilla.org/en-US/docs/Web/API/Response/ok) is not `true` (like 404), +will throw a NetworkError. + +### content {#content} + +Controls how the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) body is parsed. +When set, the return type is inferred automatically, and `schema` is constrained to `undefined` +for non-JSON content types. + +| Value | Parses via | Return type | +| --------------- | ----------------------------------------------------------------------------------------------- | ---------------------------- | +| `'json'` | [response.json()](https://developer.mozilla.org/en-US/docs/Web/API/Response/json) | `any` | +| `'blob'` | [response.blob()](https://developer.mozilla.org/en-US/docs/Web/API/Response/blob) | `Blob` | +| `'text'` | [response.text()](https://developer.mozilla.org/en-US/docs/Web/API/Response/text) | `string` | +| `'arrayBuffer'` | [response.arrayBuffer()](https://developer.mozilla.org/en-US/docs/Web/API/Response/arrayBuffer) | `ArrayBuffer` | +| `'stream'` | `response.body` | `ReadableStream` | +| _unset_ | Auto-detect from Content-Type header | `any` | + +When `content` is not set, `parseResponse` auto-detects the response type from the +`Content-Type` header: JSON types call `.json()`, binary types (images, `application/octet-stream`, +PDFs, etc.) call `.blob()`, and text-like types call `.text()`. + +#### File downloads {#file-download} + +For file downloads, set `content: 'blob'`. The return type is `Blob` and `schema` must be +`undefined` (binary data cannot be normalized). Use `dataExpiryLength: 0` to avoid caching +large blobs in memory. + +```ts +const downloadFile = new RestEndpoint({ + path: '/files/:id/download', + content: 'blob', + dataExpiryLength: 0, +}); +``` + +To extract the filename from the `Content-Disposition` header, override `parseResponse`: + +```ts +const downloadFile = new RestEndpoint({ + path: '/files/:id/download', + content: 'blob', + dataExpiryLength: 0, + async parseResponse(response) { + const blob = await response.blob(); + const disposition = response.headers.get('Content-Disposition'); + const filename = + disposition?.match(/filename="?(.+?)"?$/)?.[1] ?? 'download'; + return { blob, filename }; + }, + process(value): { blob: Blob; filename: string } { + return value; + }, +}); +``` + +See [file download guide](https://dataclient.io/rest/guides/network-transform#file-download) for complete usage with browser download trigger. + +### parseResponse(response): Promise {#parseResponse} + +Takes the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) and parses the body. + +When [`content`](#content) is set, it controls parsing directly. Otherwise, auto-detection runs +based on the [`Content-Type` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type): +JSON types call [.json()](https://developer.mozilla.org/en-US/docs/Web/API/Response/json), binary +types call [.blob()](https://developer.mozilla.org/en-US/docs/Web/API/Response/blob), and text-like +types call [.text()](https://developer.mozilla.org/en-US/docs/Web/API/Response/text). + +If `status` is 204, resolves as `null`. + +Override this for advanced cases like extracting headers alongside the body. + +### process(value, ...args): any {#process} + +Perform any transforms with the parsed result. Defaults to identity function (do nothing). + +`args` are the arguments the endpoint was called with. They are typed from the endpoint's [path](#path), +[searchParams](#searchParams) and [body](#body), including those set in the same [extend()](#extend) call. + +```ts +const getUser = new RestEndpoint({ path: '/users/:id' }); + +const getUserWithId = getUser.extend({ + process(value, params) { + // params is { id: string | number } + return { ...value, id: `${params.id}` }; + }, +}); +``` + +> **Tip** +> +> The return type of process can be used to set the return type of the endpoint fetch: +> +> ```ts title="getTodo.ts" {4} +> export const getTodo = new RestEndpoint({ +> path: '/todos/:id', +> // The identity function is the default value; so we aren't changing any runtime behavior +> process(value): TodoInterface { +> return value; +> }, +> }); +> +> interface TodoInterface { +> id: string; +> title: string; +> completed: boolean; +> } +> ``` +> +> ```ts title="useTodo.ts" +> import { getTodo } from './getTodo'; +> +> async (id: string) => { +> // hover title to see it is a string +> // see TS autocomplete by deleting `.title` and retyping the `.` +> const title = (await getTodo({ id })).title; +> }; +> ``` + +## Endpoint Lifecycle + +### schema?: Schema {#schema} + +[Declarative data lifecycle](https://dataclient.io/rest/api/schema) + +- Global data consistency and performance with [DRY](https://www.plutora.com/blog/understanding-the-dry-dont-repeat-yourself-principle) state: [where](https://dataclient.io/rest/api/schema) to expect [Entities](https://dataclient.io/rest/api/Entity) +- Functions to [deserialize fields](https://dataclient.io/rest/guides/network-transform#deserializing-fields) +- [Race condition handling](https://dataclient.io/rest/api/Entity#shouldreorder) +- [Validation](https://dataclient.io/rest/api/Entity#validate) + +```tsx +import { Entity, RestEndpoint } from '@data-client/rest'; + +class User extends Entity { + id = ''; + username = ''; +} + +const getUser = new RestEndpoint({ + path: '/users/:id', + schema: User, +}); +``` + +### key(urlParams): string {#key} + +Serializes the parameters. This is used to build a lookup key in global stores. + +Default: + +```typescript +`${this.method} ${this.url(urlParams)}`; +``` + +### testKey(key): boolean {#testKey} + +Returns `true` if the provided (fetch) [key](#key) matches this endpoint. + +This is used for mock interceptors with with [\](https://dataclient.io/docs/api/MockResolver), +[Controller.expireAll()](https://dataclient.io/vue/api/Controller#expireAll), and [Controller.invalidateAll()](https://dataclient.io/vue/api/Controller#invalidateAll). + +### dataExpiryLength?: number {#dataexpirylength} + +Custom data cache lifetime for the fetched resource. Will override the value set in NetworkManager. + +[Learn more about expiry time](https://dataclient.io/vue/concepts/expiry-policy#expiry-time) + +### errorExpiryLength?: number {#errorexpirylength} + +Custom data error lifetime for the fetched resource. Will override the value set in NetworkManager. + +### errorPolicy?: (error: any) => 'soft' | undefined {#errorpolicy} + +'soft' will use stale data (if exists) in case of error; undefined or not providing option will result +in error. + +[Learn more about errorPolicy](https://dataclient.io/vue/concepts/error-policy) + +```ts +errorPolicy(error) { + return error.status >= 500 ? 'soft' : undefined; +} +``` + +### invalidIfStale: boolean {#invalidifstale} + +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 {#pollfrequency} + +Frequency in millisecond to poll at. Requires using [useSubscription()](https://dataclient.io/vue/api/useSubscription) or +[useLive()](https://dataclient.io/vue/api/useLive) to have an effect. + +### getOptimisticResponse: (snap, ...args) => expectedResponse {#getoptimisticresponse} + +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. + +```ts title="Post" +import { Entity, EntityMixin } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +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, + }; + }, +}); +``` + +```html title="PostItem.vue" {9} + + + +``` + +```html title="TotalVotes.vue" {13} + + + +``` + +```html title="PostList.vue" + + + +``` + +[Optimistic update guide](https://dataclient.io/rest/guides/optimistic-updates) + +### update() {#update} + +```ts +(normalizedResponseOfThis, ...args) => + ({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) }) +``` + +> **Tip** +> +> Try using [Collections](https://dataclient.io/rest/api/Collection) instead. +> +> They are much easier to use and more robust! + +```ts title="UpdateType.ts" +type UpdateFunction< + Source extends EndpointInterface, + Updaters extends Record = Record, +> = ( + source: ResultEntry, + ...args: Parameters +) => { [K in keyof Updaters]: (result: Updaters[K]) => Updaters[K] }; +``` + +Simplest case: + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId: string) => ({ + [userList.key()]: (users = []) => [newUserId, ...users], + }), +}); +``` + +More updates: + +```html title="Component.vue" + +``` + +The endpoint below ensures the new user shows up immediately in the usages above. + +```ts title="userEndpoint.ts" +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): RestEndpoint {#extend} + +Can be used to further customize the endpoint definition + +```typescript +const getUser = new RestEndpoint({ path: '/users/:id' }); + +const UserDetailNormalized = getUser.extend({ + schema: User, + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': getAuth(), + }; + }, +}); +``` + +## Specialized extenders + +These convenience accessors create new endpoints for common [Collection](https://dataclient.io/rest/api/Collection) operations. +They only work when the `RestEndpoint`'s schema contains a [Collection](https://dataclient.io/rest/api/Collection). + +### push + +Creates a POST endpoint that places newly created Entities at the _end_ of a [Collection](https://dataclient.io/rest/api/Collection). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.push](https://dataclient.io/rest/api/Collection#push) + +```tsx +import { RestEndpoint, Collection } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { Todo } from './resources'; + +const getTodos = new RestEndpoint({ + path: '/todos', + searchParams: {} as { userId?: string }, + schema: new Collection([Todo]), +}); +const ctrl = useController(); + +// POST /todos - adds new Todo to the end of the list +const newTodo = await ctrl.fetch( + getTodos.push, + { userId: '1' }, + { title: 'Buy groceries' }, +); +``` + +```tsx +import { resource } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { User } from './resources'; + +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); +const ctrl = useController(); + +// POST /groups/five/users - adds new User to the end of the list +const newUser = await ctrl.fetch( + UserResource.getList.push, + { group: 'five' }, + { username: 'newuser', email: 'new@example.com' }, +); +``` + +### unshift + +Creates a POST endpoint that places newly created Entities at the _start_ of a [Collection](https://dataclient.io/rest/api/Collection). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.unshift](https://dataclient.io/rest/api/Collection#unshift) + +```tsx +import { RestEndpoint, Collection } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { Todo } from './resources'; + +const getTodos = new RestEndpoint({ + path: '/todos', + searchParams: {} as { userId?: string }, + schema: new Collection([Todo]), +}); +const ctrl = useController(); + +// POST /todos - adds new Todo to the beginning of the list +const newTodo = await ctrl.fetch( + getTodos.unshift, + { userId: '1' }, + { title: 'Urgent task' }, +); +``` + +```tsx +import { resource } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { User } from './resources'; + +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); +const ctrl = useController(); + +// POST /groups/five/users - adds new User to the start of the list +const newUser = await ctrl.fetch( + UserResource.getList.unshift, + { group: 'five' }, + { username: 'priorityuser', email: 'priority@example.com' }, +); +``` + +### assign + +Creates a POST endpoint that merges Entities into a [Values](https://dataclient.io/rest/api/Values) [Collection](https://dataclient.io/rest/api/Collection). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.assign](https://dataclient.io/rest/api/Collection#assign) + +```tsx +import { RestEndpoint, Collection, Values } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { Stats } from './resources'; + +const getStats = new RestEndpoint({ + path: '/products/stats', + schema: new Collection(new Values(Stats)), +}); +const ctrl = useController(); + +// POST /products/stats - add/update entries in the Values collection +await ctrl.fetch(getStats.assign, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, + 'ETH-USD': { product_id: 'ETH-USD', volume: 500 }, +}); +``` + +```tsx +import { resource, Collection, Values } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { Stats } from './resources'; + +const StatsResource = resource({ + urlPrefix: 'https://api.exchange.example.com', + path: '/products/:product_id/stats', + schema: Stats, +}).extend({ + getList: { + path: '/products/stats', + schema: new Collection(new Values(Stats)), + }, +}); +const ctrl = useController(); + +// POST /products/stats - add/update entries +await ctrl.fetch(StatsResource.getList.assign, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, +}); +``` + +### remove + +Creates a PATCH endpoint that removes Entities from a [Collection](https://dataclient.io/rest/api/Collection) and updates them with the response. + +Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collection.remove](https://dataclient.io/rest/api/Collection#remove) + +```tsx +import { RestEndpoint, Collection } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { Todo } from './resources'; + +const getTodos = new RestEndpoint({ + path: '/todos', + schema: new Collection([Todo]), +}); +const ctrl = useController(); + +// PATCH /todos - removes Todo from collection AND updates the entity +await ctrl.fetch(getTodos.remove, { id: '123', completed: true }); +``` + +```tsx +import { resource } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { User } from './resources'; + +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); +const ctrl = useController(); + +// PATCH /groups/five/users - removes user from 'five' group list +// AND updates the user entity with response data (e.g., new group) +await ctrl.fetch( + UserResource.getList.remove, + { group: 'five' }, + { id: '2', group: 'newgroup' }, +); +``` + +To use the remove schema with a different endpoint (e.g., DELETE): + +```ts +const deleteAndRemove = MyResource.delete.extend({ + schema: MyResource.getList.schema.remove, +}); +``` + +### move + +Creates a PATCH endpoint that moves Entities between [Collections](https://dataclient.io/rest/api/Collection). It removes from +collections matching the entity's existing state and adds to collections matching the new values +(from the body/last arg). + +Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collection.move](https://dataclient.io/rest/api/Collection#move) + +```ts title="TaskResource" +import { Entity, resource } from '@data-client/rest'; + +export class Task extends Entity { + id = ''; + title = ''; + status = 'backlog'; + pk() { return this.id; } + static key = 'Task'; +} +export const TaskResource = resource({ + path: '/tasks/:id', + searchParams: {} as { status: string }, + schema: Task, + optimistic: true, +}); +``` + +```html title="TaskCard.vue" {7-16} + + + +``` + +```html title="TaskBoard.vue" + + + +``` + +The remove filter is based on the entity's **existing** values in the store. +The add filter is based on the merged entity values (existing + body). +This uses the same [createCollectionFilter](https://dataclient.io/rest/api/Collection#createcollectionfilter) logic as push/remove. + +```tsx +import { resource } from '@data-client/rest'; +import { useController } from '@data-client/react'; +import { User } from './resources'; + +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); +const ctrl = useController(); + +// PATCH /groups/five/users/5 - moves user 5 from 'five' group to 'ten' group +await ctrl.fetch( + UserResource.getList.move, + { group: 'five', id: '2' }, + { id: '2', group: 'ten' }, +); +``` + +### getPage + +An endpoint to retrieve the next page using [paginationField](#paginationfield) as the searchParameter key. Schema +must also contain a [Collection](https://dataclient.io/rest/api/Collection) + +```html + + + + + +``` + +See [pagination guide](https://dataclient.io/rest/guides/pagination) for more info. + +### paginated(paginationfield) {#paginated} + +Creates a new endpoint with an extra `paginationfield` string that will be used to find the specific +page, to append to this endpoint. See [Infinite Scrolling Pagination](https://dataclient.io/rest/guides/pagination#infinite-scrolling) for more info. + +```ts +const getNextPage = getList.paginated('cursor'); +``` + +Schema must also contain a [Collection](https://dataclient.io/rest/api/Collection) + +### paginated(removeCursor) {#paginated-function} + +```typescript +function paginated( + this: E, + removeCursor: (...args: A) => readonly [...Parameters], +): PaginationEndpoint; +``` + +The function form allows any argument processing. This is the equivalent of sending `cursor` string like above. + +```ts +const getNextPage = getList.paginated( + ({ cursor, ...rest }: { cursor: string | number }) => + (Object.keys(rest).length ? [rest] : []) as any, +); +``` + +`removeCusor` is a function that takes the arguments sent in fetch of `getNextPage` and returns +the arguments to update `getList`. + +Schema must also contain a [Collection](https://dataclient.io/rest/api/Collection) + +## Inheritance + +Make sure you use `RestGenerics` to keep types working. + +```ts +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; + +class GithubEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = 'https://api.github.com'; + + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': getAuth(), + }; + } +} +``` diff --git a/.agents/skills/data-client-setup/references/data-client-rest-setup/references/auth.md b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/auth.md new file mode 100644 index 000000000000..cbb73ad2e40f --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/auth.md @@ -0,0 +1,400 @@ + + +# Rest Authentication + +All network requests are run through the [getRequestInit](./RestEndpoint.md#getRequestInit) optionally +defined in your [RestEndpoint](./RestEndpoint.md). + +## Cookie Auth (credentials) + +Here's an example using simple [cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) auth by sending [fetch credentials](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#sending_a_request_with_credentials_included): + +```ts title="AuthdEndpoint" {9} +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async getRequestInit(body: any): Promise { + return { + ...(await super.getRequestInit(body)), + credentials: 'same-origin', + }; + } +} +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +See [Django Integration](./django.md) for an example that also includes [CSRF protection](https://docs.djangoproject.com/en/5.0/howto/csrf/#using-csrf-protection-with-ajax). + +## Access Tokens or JWT + +**static member** + +```ts title="login" +export const login = async (data: FormData) => + ( + await fetch('/login', { method: 'POST', body: data }) + ).json() as Promise<{ + accessToken: string; + }>; +``` + +```ts title="AuthdEndpoint" {7,15,22} +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; +import { login } from './login'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + declare static accessToken?: string; + + getHeaders(headers: HeadersInit) { + // TypeScript doesn't infer properly + const EP = this.constructor as typeof AuthdEndpoint; + if (!EP.accessToken) return headers; + return { + ...headers, + 'Access-Token': EP.accessToken, + }; + } +} + +export const handleLogin = async e => { + const { accessToken } = await login(new FormData(e.target)); + AuthdEndpoint.accessToken = accessToken; +}; +``` + +```tsx title="Auth" +import { handleLogin } from './AuthdEndpoint'; + +export default function Auth() { + return ; +} +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +**async function** + +```ts title="login" +export const login = async (data: FormData) => + ( + await fetch('/login', { method: 'POST', body: data }) + ).json() as Promise<{ + accessToken: string; + }>; + +let token = ''; +// imagine this used an async API like indexedDB +export const getAuthToken = async () => token; +export const setAuthToken = (accessToken: string) => { + token = accessToken; +}; +``` + +```ts title="AuthdEndpoint" {10,17} +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; +import { getAuthToken, setAuthToken, login } from './login'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async getHeaders(headers: HeadersInit) { + return { + ...headers, + 'Access-Token': await getAuthToken(), + }; + } +} + +export const handleLogin = async e => { + const { accessToken } = await login(new FormData(e.target)); + setAuthToken(accessToken); +}; +``` + +```tsx title="Auth" +import { handleLogin } from './AuthdEndpoint'; + +export default function Auth() { + return ; +} +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; + pk() { + return this.id; + } +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +**function singleton** + +```ts title="login" +export const login = async (data: FormData) => + ( + await fetch('/login', { method: 'POST', body: data }) + ).json() as Promise<{ + accessToken: string; + }>; + +let token = ''; +export const getAuthToken = () => token; +export const setAuthToken = (accessToken: string) => { + token = accessToken; +}; +``` + +```ts title="AuthdEndpoint" {10,17} +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; +import { getAuthToken, setAuthToken, login } from './login'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + getHeaders(headers: HeadersInit) { + return { + ...headers, + 'Access-Token': getAuthToken(), + }; + } +} + +export const handleLogin = async e => { + const { accessToken } = await login(new FormData(e.target)); + setAuthToken(accessToken); +}; +``` + +```tsx title="Auth" +import { handleLogin } from './AuthdEndpoint'; + +export default function Auth() { + return ; +} +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; + pk() { + return this.id; + } +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +## Auth Headers from React Context {#auth-headers-from-react-context} + +> **Warning** +> +> Using React Context for state that is not displayed (like auth tokens) is not recommended. +> This will result in unnecessary re-renders and application complexity. + +**Resource** + +We can transform any [Resource](./resource.md) into one that uses hooks to create endpoints +by using [hookifyResource](./hookifyResource.md) + +```ts title="resources/Post.ts" +import { Entity, resource, hookifyResource } from '@data-client/rest'; +import { useAuthContext } from '../AuthContext'; + +class Post extends Entity { + id = ''; + title = ''; +} + +export const PostResource = hookifyResource( + resource({ path: '/posts/:id', schema: Post }), + function useInit(): RequestInit { + const accessToken = useAuthContext(); + return { + headers: { + 'Access-Token': accessToken, + }, + }; + }, +); +``` + +Then we can get the endpoints as hooks in our React Components + +```tsx +import { useSuspense } from '@data-client/react'; +import { PostResource } from 'resources/Post'; + +function PostDetail({ id }) { + const post = useSuspense(PostResource.useGet(), { id }); + return
{post.title}
; +} +``` + +> **Warning** +> +> Using this means all endpoint calls must only occur during a function render. +> +> ```tsx +> import { useController } from '@data-client/react'; +> import { PostResource } from './resources/Post'; +> +> function CreatePost() { +> const controller = useController(); +> const createPost = PostResource.useCreate(); +> +> return ( +>
onSubmit={e => +> controller.fetch(createPost, new FormData(e.currentTarget)) +> } +> > +> {/* ... */} +>
+> ); +> } +> ``` + +**RestEndpoint** + +We will first provide an easy way of using the context to alter the fetch headers. + +```ts title="api/AuthdEndpoint.ts" +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + declare accessToken?: string; + + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': this.accessToken, + }; + } +} +``` + +Next we will [extend](./RestEndpoint.md#extend) to generate a new endpoint with this context injected. + +```tsx +import { useMemo } from 'react'; +import type { IRestEndpoint } from '@data-client/rest'; +import { useAuthContext } from './AuthContext'; + +function useEndpoint(endpoint: IRestEndpoint) { + const accessToken = useAuthContext(); + return useMemo( + () => endpoint.extend({ accessToken }), + [endpoint, accessToken], + ); +} +``` + +> **Warning** +> +> Using this means all endpoint calls must only occur during a function render. +> +> ```tsx +> import { useController } from '@data-client/react'; +> import { PostResource } from './api/Post'; +> import { useEndpoint } from './useEndpoint'; +> +> function CreatePost() { +> const controller = useController(); +> const createPost = useEndpoint(PostResource.create); +> +> return ( +>
onSubmit={e => +> controller.fetch(createPost, {}, new FormData(e.target)) +> } +> > +> {/* ... */} +>
+> ); +> } +> ``` + +## Code organization + +If much of your `Resources` share a similar auth mechanism, you might +try extending from a base class that defines such common customizations. + +## 401 Logout Handling + +In case a users authorization expires, the server will typically responsd to indicate +as such. The standard way of doing this is with a 401. [LogoutManager](https://dataclient.io/docs/api/LogoutManager) +can be used to easily trigger any de-authorization cleanup. diff --git a/.agents/skills/data-client-setup/references/data-client-rest-setup/references/auth.vue.md b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/auth.vue.md new file mode 100644 index 000000000000..053949769e21 --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/auth.vue.md @@ -0,0 +1,405 @@ + + +# Rest Authentication + +All network requests are run through the [getRequestInit](./RestEndpoint.vue.md#getRequestInit) optionally +defined in your [RestEndpoint](./RestEndpoint.vue.md). + +## Cookie Auth (credentials) + +Here's an example using simple [cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) auth by sending [fetch credentials](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#sending_a_request_with_credentials_included): + +```ts title="AuthdEndpoint" {9} +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async getRequestInit(body: any): Promise { + return { + ...(await super.getRequestInit(body)), + credentials: 'same-origin', + }; + } +} +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +See [Django Integration](./django.md) for an example that also includes [CSRF protection](https://docs.djangoproject.com/en/5.0/howto/csrf/#using-csrf-protection-with-ajax). + +## Access Tokens or JWT + +**static member** + +```ts title="login" +export const login = async (data: FormData) => + ( + await fetch('/login', { method: 'POST', body: data }) + ).json() as Promise<{ + accessToken: string; + }>; +``` + +```ts title="AuthdEndpoint" {7,15,22} +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; +import { login } from './login'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + declare static accessToken?: string; + + getHeaders(headers: HeadersInit) { + // TypeScript doesn't infer properly + const EP = this.constructor as typeof AuthdEndpoint; + if (!EP.accessToken) return headers; + return { + ...headers, + 'Access-Token': EP.accessToken, + }; + } +} + +export const handleLogin = async e => { + const { accessToken } = await login(new FormData(e.target)); + AuthdEndpoint.accessToken = accessToken; +}; +``` + +```html title="Auth.vue" + + + +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +**async function** + +```ts title="login" +export const login = async (data: FormData) => + ( + await fetch('/login', { method: 'POST', body: data }) + ).json() as Promise<{ + accessToken: string; + }>; + +let token = ''; +// imagine this used an async API like indexedDB +export const getAuthToken = async () => token; +export const setAuthToken = (accessToken: string) => { + token = accessToken; +}; +``` + +```ts title="AuthdEndpoint" {10,17} +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; +import { getAuthToken, setAuthToken, login } from './login'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async getHeaders(headers: HeadersInit) { + return { + ...headers, + 'Access-Token': await getAuthToken(), + }; + } +} + +export const handleLogin = async e => { + const { accessToken } = await login(new FormData(e.target)); + setAuthToken(accessToken); +}; +``` + +```html title="Auth.vue" + + + +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; + pk() { + return this.id; + } +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +**function singleton** + +```ts title="login" +export const login = async (data: FormData) => + ( + await fetch('/login', { method: 'POST', body: data }) + ).json() as Promise<{ + accessToken: string; + }>; + +let token = ''; +export const getAuthToken = () => token; +export const setAuthToken = (accessToken: string) => { + token = accessToken; +}; +``` + +```ts title="AuthdEndpoint" {10,17} +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; +import { getAuthToken, setAuthToken, login } from './login'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + getHeaders(headers: HeadersInit) { + return { + ...headers, + 'Access-Token': getAuthToken(), + }; + } +} + +export const handleLogin = async e => { + const { accessToken } = await login(new FormData(e.target)); + setAuthToken(accessToken); +}; +``` + +```html title="Auth.vue" + + + +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; + pk() { + return this.id; + } +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +## Auth Headers from provide/inject {#auth-headers-from-react-context} + +> **Warning** +> +> Using provide/inject for state that is not displayed (like auth tokens) is not recommended. +> This will result in unnecessary re-renders and application complexity. + +**Resource** + +We can transform any [Resource](./resource.vue.md) into one that uses composables to create endpoints +by using [hookifyResource](./hookifyResource.vue.md) + +```ts title="resources/Post.ts" +import { inject } from 'vue'; +import { resource, hookifyResource } from '@data-client/rest'; + +// Post defined here + +export const AuthKey = Symbol('accessToken'); + +export const PostResource = hookifyResource( + resource({ path: '/posts/:id', schema: Post }), + function useInit(): RequestInit { + const accessToken = inject(AuthKey, ''); + return { + headers: { + 'Access-Token': accessToken, + }, + }; + }, +); +``` + +Then we can get the endpoints as composables in our Vue Components + +```html title="PostDetail.vue" + + + +``` + +> **Warning** +> +> Using this means all endpoint calls must only occur at the top level of ` +> +> +> ``` + +**RestEndpoint** + +We will first provide an easy way of using the context to alter the fetch headers. + +```ts title="api/AuthdEndpoint.ts" +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + declare accessToken?: string; + + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': this.accessToken, + }; + } +} +``` + +Next we will [extend](./RestEndpoint.vue.md#extend) to generate a new endpoint with this context injected. + +```ts +import { inject } from 'vue'; + +function useEndpoint(endpoint: RestEndpoint) { + const accessToken = inject(AuthKey, ''); + return endpoint.extend({ accessToken }); +} +``` + +> **Warning** +> +> Using this means all endpoint calls must only occur at the top level of ` +> +> +> ``` + +## Code organization + +If much of your `Resources` share a similar auth mechanism, you might +try extending from a base class that defines such common customizations. + +## 401 Logout Handling + +In case a users authorization expires, the server will typically responsd to indicate +as such. The standard way of doing this is with a 401. [LogoutManager](https://dataclient.io/vue/api/LogoutManager) +can be used to easily trigger any de-authorization cleanup. diff --git a/.agents/skills/data-client-setup/references/data-client-rest-setup/references/axios-migration.md b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/axios-migration.md new file mode 100644 index 000000000000..12b4a2c8af1c --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/axios-migration.md @@ -0,0 +1,761 @@ + + +# Migrating from Axios + +[`@data-client/rest`](https://dataclient.io/rest) replaces axios with a declarative, type-safe approach to REST APIs. + +## Why migrate? + +### Type-safe paths + +With axios, API paths are opaque strings — typos and missing parameters are only caught at runtime: + +```ts +// axios: no type checking — typo silently produces wrong URL +axios.get(`/users/${usrId}`); +``` + +With [`RestEndpoint`](./RestEndpoint.md), path parameters are inferred from the `path` template and enforced at compile time: + +```ts +const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); +// TypeScript enforces { id: string } — typos are compile errors +getUser({ id: '1' }); +``` + +This also means IDE autocomplete works for every path parameter. + +### Additional benefits + +- **Normalized cache** — shared entities are deduplicated and updated everywhere automatically +- **Declarative data dependencies** — components declare what data they need via [`useSuspense()`](https://dataclient.io/docs/api/useSuspense), not how to fetch it +- **Optimistic updates** — instant UI feedback before the server responds +- **Zero boilerplate** — [`resource()`](./resource.md) generates a full CRUD API from a `path` and `schema` + +## Quick reference + +| Axios | @data-client/rest | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseURL` | [`urlPrefix`](./RestEndpoint.md#urlPrefix) | +| `headers` config | [`getHeaders()`](./RestEndpoint.md#getHeaders) | +| `interceptors.request` | [`getRequestInit()`](./RestEndpoint.md#getRequestInit) / [`getHeaders()`](./RestEndpoint.md#getHeaders) | +| `interceptors.response` | [`parseResponse()`](./RestEndpoint.md#parseResponse) / [`process()`](./RestEndpoint.md#process) | +| `timeout` | [`AbortSignal.timeout()`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static) via `signal` | +| `params` / `paramsSerializer` | [`searchParams`](./RestEndpoint.md#searchParams) / [`searchToString()`](./RestEndpoint.md#searchToString) | +| `cancelToken` / `signal` | `signal` ([AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)) | +| `responseType: 'blob'` / `'arraybuffer'` | [`content: 'blob'`](./RestEndpoint.md#content) / `'arrayBuffer'` — see [file download](https://dataclient.io/rest/guides/network-transform#file-download) | +| `auth: { username, password }` | [`getHeaders()`](./RestEndpoint.md#getHeaders) with `btoa()` | +| `xsrfCookieName` / `xsrfHeaderName` | [`getHeaders()`](./RestEndpoint.md#getHeaders) — see [Django Integration](./django.md) | +| `transformRequest` | [`getRequestInit()`](./RestEndpoint.md#getRequestInit) | +| `transformResponse` | [`process()`](./RestEndpoint.md#process) | +| `validateStatus` | Custom [`fetchResponse()`](./RestEndpoint.md#fetchResponse) | +| `onUploadProgress` | Custom [`fetchResponse()`](./RestEndpoint.md#fetchResponse) using [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) | +| `isAxiosError` / `error.response` | [`NetworkError`](./RestEndpoint.md#fetchResponse) with `.status` and `.response` | + +## Migration examples + +### Basic GET + +**Before (axios)** + +```ts title="api.ts" +import axios from 'axios'; + +export const getUser = (id: string) => + axios.get(`https://api.example.com/users/${id}`); +``` + +```ts title="usage.ts" +const { data } = await getUser('1'); +``` + +**After (data-client)** + +```ts title="User" +import { Entity } from '@data-client/rest'; + +export default class User extends Entity { + id = ''; + username = ''; + email = ''; + static key = 'User'; +} +``` + +```ts title="api" +import { RestEndpoint } from '@data-client/rest'; +import User from './User'; + +export const getUser = new RestEndpoint({ + urlPrefix: 'https://api.example.com', + path: '/users/:id', + schema: User, +}); +``` + +```ts title="Usage" column +import { getUser } from './api'; +getUser({ id: '1' }); +``` + +### Instance with base URL and headers + +**Before (axios)** + +```ts title="api.ts" +import axios from 'axios'; + +const api = axios.create({ + baseURL: 'https://api.example.com', + headers: { 'X-API-Key': 'my-key' }, +}); + +export const getPost = (id: string) => api.get(`/posts/${id}`); +export const createPost = (data: any) => api.post('/posts', data); +``` + +**After (data-client)** + +```ts title="Post" +import { Entity } from '@data-client/rest'; + +export default class Post extends Entity { + id = ''; + title = ''; + body = ''; + static key = 'Post'; +} +``` + +```ts title="ApiEndpoint" +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = 'https://api.example.com'; + + getHeaders(headers: HeadersInit) { + return { + ...headers, + 'X-API-Key': 'my-key', + }; + } +} +``` + +```ts title="PostResource" +import { resource } from '@data-client/rest'; +import ApiEndpoint from './ApiEndpoint'; +import Post from './Post'; + +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + Endpoint: ApiEndpoint, +}); +``` + +```ts title="Usage" column +import { PostResource } from './PostResource'; +PostResource.get({ id: '1' }); +``` + +### POST mutation + +**Before (axios)** + +```ts title="api.ts" +import axios from 'axios'; + +const api = axios.create({ baseURL: 'https://api.example.com' }); + +export const createPost = (data: { title: string; body: string }) => + api.post('/posts', data); +``` + +**After (data-client)** + +```ts title="Post" +import { Entity } from '@data-client/rest'; + +export default class Post extends Entity { + id = ''; + title = ''; + body = ''; + static key = 'Post'; +} +``` + +```ts title="PostResource" +import { resource } from '@data-client/rest'; +import Post from './Post'; + +export const PostResource = resource({ + urlPrefix: 'https://api.example.com', + path: '/posts/:id', + schema: Post, +}); +``` + +```ts title="Usage" column +import { PostResource } from './PostResource'; +PostResource.getList.push({ + title: 'New Post', + body: 'Content', +}); +``` + +### Interceptors → lifecycle methods + +Axios interceptors map to [RestEndpoint](./RestEndpoint.md) lifecycle methods: + +**Before (axios)** + +```ts title="api.ts" +import axios from 'axios'; + +const api = axios.create({ baseURL: 'https://api.example.com' }); + +// Request interceptor — add auth token +api.interceptors.request.use(config => { + config.headers.Authorization = `Bearer ${getToken()}`; + return config; +}); + +// Response interceptor — unwrap .data +api.interceptors.response.use( + response => response.data, + error => Promise.reject(error), +); +``` + +**After (data-client)** + +```ts title="ApiEndpoint.ts" +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = 'https://api.example.com'; + + // Equivalent to request interceptor + getHeaders(headers: HeadersInit) { + return { + ...headers, + Authorization: `Bearer ${getToken()}`, + }; + } + + // Equivalent to response interceptor (unwrap/transform) + process(value: any, ...args: any) { + return value; + } +} +``` + +> **Tip** +> +> `RestEndpoint` already returns parsed JSON by default — no interceptor needed to unwrap `response.data`. + +Response interceptors that transform the body, such as converting `snake_case` keys, belong in [`process()`](./RestEndpoint.md#process). See [snakes to camels](https://dataclient.io/rest/guides/network-transform#snakes-to-camels) for a complete example. + +### Error handling + +**Before (axios)** + +```ts +import axios from 'axios'; + +try { + const { data } = await axios.get('/users/1'); +} catch (err) { + if (axios.isAxiosError(err)) { + console.log(err.response?.status); + console.log(err.response?.data); + } +} +``` + +**After (data-client)** + +```ts +import { NetworkError } from '@data-client/rest'; + +try { + const user = await getUser({ id: '1' }); +} catch (err) { + if (err instanceof NetworkError) { + console.log(err.status); + console.log(err.response); + } +} +``` + +[`NetworkError`](./RestEndpoint.md#fetchResponse) provides `.status` and `.response` (the raw [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) object). For soft retries on server errors, see [`errorPolicy`](./RestEndpoint.md#errorpolicy). + +#### Server error messages + +Axios codebases commonly surface `error.response.data.error` or `.message` to the user. Read it from the `Response` body instead, once, in the base class's [`fetchResponse()`](./RestEndpoint.md#fetchResponse), so call sites get it from `error.message` without parsing the body: + +```ts title="ApiEndpoint.ts" +import { + NetworkError, + RestEndpoint, + RestGenerics, +} from '@data-client/rest'; + +export default class ApiEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async fetchResponse(input: RequestInfo, init: RequestInit) { + try { + return await super.fetchResponse(input, init); + } catch (error) { + if (error instanceof NetworkError) { + const body = await error.response + .clone() + .json() + .catch(() => null); + // keep the NetworkError so `status` and `errorPolicy()` still work + error.message = body?.error ?? body?.message ?? error.message; + } + throw error; + } + } +} +``` + +### Cancellation + +**Before (axios)** + +```ts +import axios from 'axios'; + +const controller = new AbortController(); +axios.get('/users', { signal: controller.signal }); +controller.abort(); +``` + +Or with the deprecated `CancelToken`: + +```ts +const source = axios.CancelToken.source(); +axios.get('/users', { cancelToken: source.token }); +source.cancel(); +``` + +**After (data-client)** + +Both map to an [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) `signal`. The [`useCancelling()`](https://dataclient.io/docs/api/useCancelling) hook automatically cancels in-flight requests when parameters change: + +```tsx +import { useSuspense } from '@data-client/react'; +import { useCancelling } from '@data-client/react'; +import { searchEndpoint } from './api/search'; +import ResultsList from './ResultsList'; + +function SearchResults({ query }: { query: string }) { + const results = useSuspense(useCancelling(searchEndpoint), { q: query }); + return ; +} +``` + +For manual cancellation, pass `signal` directly: + +```ts +const controller = new AbortController(); +const getUser = new RestEndpoint({ + path: '/users/:id', + signal: controller.signal, +}); +controller.abort(); +``` + +See the [abort guide](https://dataclient.io/rest/guides/abort) for more patterns. + +### Timeout + +```ts title="Before (axios)" +axios.get('/users', { timeout: 5000 }); +``` + +```ts title="After (data-client)" +const getUsers = new RestEndpoint({ + path: '/users', + signal: AbortSignal.timeout(5000), +}); +``` + +### Binary responses + +```ts title="Before (axios)" +axios.get('/files/1', { responseType: 'blob' }); +``` + +Set [`content`](./RestEndpoint.md#content) to `'blob'`, `'arrayBuffer'` or `'text'`. See [file download](https://dataclient.io/rest/guides/network-transform#file-download) for the full endpoint and triggering a browser download. + +### Query serialization + +```ts title="Before (axios)" +axios.get('/users', { + params: { ids: [1, 2, 3] }, + paramsSerializer: params => + qs.stringify(params, { arrayFormat: 'repeat' }), +}); +``` + +Override [`searchToString()`](./RestEndpoint.md#searchToString) to serialize with `qs`; see [using the `qs` library](./RestEndpoint.md#searchToString). + +### Basic auth + +```ts title="Before (axios)" +axios.get('/api', { auth: { username: 'user', password: 'pass' } }); +``` + +```ts title="After (data-client)" +export default class BasicAuthEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + getHeaders(headers: HeadersInit) { + return { + ...headers, + Authorization: `Basic ${btoa('user:pass')}`, + }; + } +} +``` + +### Accepting error statuses + +[`fetchResponse()`](./RestEndpoint.md#fetchResponse) throws [`NetworkError`](./RestEndpoint.md#fetchResponse) for any non-`ok` status. Override it to change what counts as an error: + +```ts title="Before (axios)" +axios.get('/api', { validateStatus: status => status < 500 }); +``` + +```ts title="After (data-client)" +import { + NetworkError, + RestEndpoint, + RestGenerics, +} from '@data-client/rest'; + +export default class LenientEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async fetchResponse(input: RequestInfo, init: RequestInit) { + const response = await fetch(input, init); + if (response.status >= 500) throw new NetworkError(response); + return response; + } +} +``` + +### CSRF headers + +```ts title="Before (axios)" +axios.create({ + xsrfCookieName: 'csrftoken', + xsrfHeaderName: 'X-CSRFToken', +}); +``` + +Read the cookie in [`getHeaders()`](./RestEndpoint.md#getHeaders) for non-`GET` requests. See [Django Integration](./django.md) for the complete endpoint class. + +### Upload progress + +`fetch` cannot report upload progress, so use [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) inside [`fetchResponse()`](./RestEndpoint.md#fetchResponse). The `onProgress` field is passed as an endpoint option, like any other member. + +```ts title="Before (axios)" +axios.post('/upload', formData, { + onUploadProgress: e => console.log(e.loaded / e.total), +}); +``` + +```ts title="After (data-client)" +import { + NetworkError, + RestEndpoint, + RestGenerics, +} from '@data-client/rest'; + +export default class UploadEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + declare onProgress?: (progress: number) => void; + + fetchResponse(input: RequestInfo, init: RequestInit) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const abort = () => xhr.abort(); + const abortError = () => + new DOMException('The operation was aborted.', 'AbortError'); + if (init.signal?.aborted) return reject(abortError()); + init.signal?.addEventListener('abort', abort, { once: true }); + + xhr.open( + init.method ?? 'POST', + typeof input === 'string' ? input : input.url, + ); + new Headers(init.headers).forEach((value, key) => + xhr.setRequestHeader(key, value), + ); + xhr.onloadend = () => + init.signal?.removeEventListener('abort', abort); + xhr.upload.onprogress = e => { + if (e.lengthComputable) this.onProgress?.(e.loaded / e.total); + }; + xhr.onload = () => { + const headers = new Headers(); + for (const line of xhr + .getAllResponseHeaders() + .trim() + .split(/\r?\n/)) { + const [key, ...rest] = line.split(': '); + if (key) headers.append(key, rest.join(': ')); + } + // 204, 205 and 304 responses can't have a body + const body = [204, 205, 304].includes(xhr.status) + ? null + : xhr.response; + const response = new Response(body, { + status: xhr.status, + statusText: xhr.statusText, + headers, + }); + if (response.ok) resolve(response); + else reject(new NetworkError(response)); + }; + xhr.onerror = () => reject(new TypeError('Network request failed')); + xhr.onabort = () => reject(abortError()); + xhr.send(init.body as XMLHttpRequestBodyInit | null); + }); + } +} + +const uploadFile = new UploadEndpoint({ + path: '/upload', + method: 'POST', + body: {} as FormData, + onProgress: (progress: number) => console.log(progress), +}); +``` + +## Codemod {#codemod} + +A standalone [jscodeshift](https://github.com/facebook/jscodeshift) codemod handles the mechanical parts of migration. + +```bash +npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/ +``` + +The codemod automatically: + +- Replaces `import axios from 'axios'` with `import { RestEndpoint } from '@data-client/rest'` +- Converts `axios.create({ baseURL, headers })` into a base `RestEndpoint` subclass with `urlPrefix` and `getHeaders()` +- Transforms `axios.get()`, `.post()`, `.put()`, `.patch()`, `.delete()` into `new RestEndpoint({ path, method })` +- Transforms calls on a created instance (`api.post()` where `api = axios.create(...)`) into `new CreatedClassName({ path, method })` + +The codemod has little to do when the project wraps axios in its own class or function and never calls `axios.get()`/`.post()` directly, or only calls `axios(config)` without a method name. In those cases, skip it and start with [the manual steps](#after-the-codemod). + +The codemod does **not** handle: + +- Interceptors — see [lifecycle methods](#interceptors--lifecycle-methods) +- Error handling (`isAxiosError`, `error.response`) — see [error handling](#error-handling) +- The rest of the [quick reference](#quick-reference) — see the [migration examples](#migration-examples) above +- [Entity](https://dataclient.io/rest/api/Entity) schema definitions and converting call sites to hooks — see [below](#after-the-codemod) + +### Finding remaining axios usage + +Search patterns for locating what still needs migrating: + +| Pattern | Finds | +| ------------------------------------------ | ------------------------- | +| `import.*from ['"]axios['"]` | import statements | +| `axios\.create` | instance creation | +| `axios\.(get\|post\|put\|patch\|delete)` | direct calls | +| `\.interceptors\.(request\|response)\.use` | interceptors | +| `isAxiosError` | error handling | +| `cancelToken\|CancelToken` | cancellation (deprecated) | +| `onUploadProgress\|onDownloadProgress` | progress callbacks | + +## After the codemod {#after-the-codemod} + +The codemod produces endpoints without schemas. Defining [Entity](https://dataclient.io/rest/api/Entity) schemas and wiring them to endpoints enables normalization and caching — the core value of Reactive Data Client. + +### Non-standard primary keys + +Many APIs (MongoDB, for example) use `_id` instead of `id`. Override [`pk()`](https://dataclient.io/rest/api/Entity#pk): + +```ts +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + _id = ''; + name = ''; + email = ''; + static key = 'User'; + + pk() { + return this._id; + } +} +``` + +### Group CRUD endpoints with resource() + +When an axios module has separate `getUsers`, `getUser`, `createUser`, `updateUser` and `deleteUser` functions for one path, replace them with a single [`resource()`](./resource.md): + +```ts +import { resource } from '@data-client/rest'; +import ApiEndpoint from './ApiEndpoint'; +import { User } from './User'; + +export const UserResource = resource({ + path: '/users/:id', + schema: User, + Endpoint: ApiEndpoint, +}); +// UserResource.getList, .get, .getList.push, .update, .partialUpdate, .delete +``` + +Nested paths like `/projects/:projectId/tasks/:taskId` get their own resource. Reserve standalone `new ApiEndpoint()` for non-CRUD operations (search, custom actions, auth). + +### Coexisting with Zod or Yup + +If the codebase already validates responses with Zod or Yup, choose one approach per type: + +- **Zod in `process()`** (recommended): keep runtime validation by parsing in [`process()`](./RestEndpoint.md#process), and let the Entity handle normalization: + + ```ts + const getUser = new ApiEndpoint({ + path: '/users/:id', + schema: User, + process(value: any) { + return userSchema.parse(value); + }, + }); + ``` + +- **Entity replaces Zod**: move the field shape into the Entity class and remove the Zod schema. Entity fields provide types, not runtime checks, so add [`static validate()`](https://dataclient.io/rest/api/Entity#validate) for any fields the server might send malformed. + +- **Zod only, no Entity**: leave `schema` unset and parse manually. Only do this for endpoints that don't benefit from normalization (auth tokens, one-off responses). + +> **Warning** +> +> Don't define Entity classes and then leave `schema` unset on every endpoint — without `schema`, nothing is normalized and the migration gains little over axios. + +### Body typing + +Type the body of standalone `POST`/`PUT`/`PATCH` endpoints with `body: {} as BodyType`. Don't use `undefined as unknown as BodyType`: `RestEndpoint` treats [`body`](./RestEndpoint.md#body)`: undefined` as having no body argument. + +```ts +const createUser = new ApiEndpoint({ + path: '/users', + method: 'POST', + body: {} as { name: string; email: string }, + schema: User, +}); +``` + +[`resource()`](./resource.md) types its CRUD endpoints automatically. + +### Convert call sites to hooks + +**Before (axios)** + +```tsx +import { useEffect, useState } from 'react'; +import api from './lib/api'; +import { Spinner } from './Spinner'; +import type { User } from './User'; + +function UserProfile({ id }: { id: string }) { + const [user, setUser] = useState(null); + useEffect(() => { + api.get(`/users/${id}`).then(({ data }) => setUser(data)); + }, [id]); + if (!user) return ; + return

{user.name}

; +} +``` + +**After (data-client)** + +```tsx +import { useSuspense } from '@data-client/react'; +import { UserResource } from './UserResource'; + +function UserProfile({ id }: { id: string }) { + const user = useSuspense(UserResource.get, { id }); + return

{user.name}

; +} +``` + +Loading and error states move to [`AsyncBoundary`](https://dataclient.io/docs/api/AsyncBoundary). See [`useSuspense()`](https://dataclient.io/docs/api/useSuspense) for details. + +### Context-based auth + +When tokens come from React context (Okta, Auth0) rather than storage, use [`hookifyResource()`](./hookifyResource.md) to inject headers through a hook. See the [authentication guide](./auth.md) for this and other patterns. + +### Gradual migration + +If the app uses TanStack Query or SWR and can't convert everything at once, keep those hooks temporarily but fetch through [`controller.fetch()`](https://dataclient.io/docs/api/Controller#fetch). Calling an endpoint directly only runs its fetch; going through the Controller also normalizes the response into the shared cache, so data is consistent from day one: + +```ts +import { useController } from '@data-client/react'; +import { useQuery } from '@tanstack/react-query'; +import ApiEndpoint from './ApiEndpoint'; +import { Project } from './Project'; + +export const getProject = new ApiEndpoint({ + path: '/projects/:id', + schema: Project, +}); + +export function useProject(id: string) { + const ctrl = useController(); + return useQuery({ + queryKey: ['project', id], + queryFn: () => ctrl.fetch(getProject, { id }), + }); +} +``` + +Later, replace `useProject(id)` with `useSuspense(getProject, { id })`. + +### Existing endpoint abstractions + +Codebases that already have a custom endpoint class wrapping axios (say, one with `path`, `method` and a `toDynamicUrl()` helper) can extend `RestEndpoint` instead of replacing it, keeping backward-compatible methods while gaining [`url()`](./RestEndpoint.md#url), [`getRequestInit()`](./RestEndpoint.md#getRequestInit), [`fetchResponse()`](./RestEndpoint.md#fetchResponse) and [`parseResponse()`](./RestEndpoint.md#parseResponse): + +```ts +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +export class LegacyEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = API_ROOT; + declare queryKey?: string; + + /** @deprecated use url() */ + toDynamicUrl = this.url; +} + +const getUser = new LegacyEndpoint({ + path: '/users/:id', + queryKey: 'user', + schema: User, +}); +``` + +Pass extra members like `queryKey` as options rather than through a custom constructor, so [`extend()`](./RestEndpoint.md#extend) (used by `resource()`, `hookifyResource()` and `useCancelling()`) keeps working. + +## Related guides + +- [Authentication](./auth.md) — token and cookie auth patterns +- [Aborting Fetch](https://dataclient.io/rest/guides/abort) — cancellation and debouncing +- [Transforming data on fetch](https://dataclient.io/rest/guides/network-transform) — response transforms, field renaming, file downloads +- [Django Integration](./django.md) — CSRF and cookie auth for Django diff --git a/.agents/skills/data-client-setup/references/data-client-rest-setup/references/django.md b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/django.md new file mode 100644 index 000000000000..3c243652b106 --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/django.md @@ -0,0 +1,72 @@ + + +# Django Integration + +## Cookie Auth + CSRF + +Django add protection against Cross Site Request Forgery, by [requiring the 'X-CSRFToken' header in requests](https://docs.djangoproject.com/en/5.0/howto/csrf/#using-csrf-protection-with-ajax). + +Additionally Django's authentication uses [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies), so we need to send credentials [fetch credentials](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#sending_a_request_with_credentials_included). If you use +an auth type other than the default 'django.contrib.auth', see [authentication guide](./auth.md) for more examples. + +```ts title="getCookie" +export default function getCookie(name): string { + let cookieValue = ''; + if (document.cookie && document.cookie != '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + // Does this cookie string begin with the name we want? + if (cookie.substring(0, name.length + 1) == (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} +``` + +```ts title="DjangoEndpoint" +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; +import getCookie from './getCookie'; + +export default class DjangoEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async getRequestInit(body: any): Promise { + return { + ...(await super.getRequestInit(body)), + credentials: 'same-origin', + }; + } + getHeaders(headers: HeadersInit) { + if (this.method === 'GET') return headers; + return { + ...headers, + 'X-CSRFToken': getCookie('csrftoken'), + }; + } +} +``` + +```ts title="MyResource" {15} +import { resource, Entity } from '@data-client/rest'; +import DjangoEndpoint from './DjangoEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: DjangoEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` diff --git a/.agents/skills/data-client-setup/references/data-client-rest-setup/references/fetch-migration.md b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/fetch-migration.md new file mode 100644 index 000000000000..58a5d01dc3d6 --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/fetch-migration.md @@ -0,0 +1,136 @@ + + +# Raw fetch → @data-client/rest Migration + +## Detection + +Look for direct `fetch()` calls or thin wrappers around fetch in the codebase: + +- `fetch(` calls with REST-style URLs (`/api/`, `/users/`, etc.) +- Custom wrapper functions that call `fetch` internally +- No HTTP client library in `package.json` + +Search patterns: +- `\bfetch\(` — direct fetch calls +- `new Request\(` — Request object construction +- `\.json\(\)` — response parsing (common in fetch wrappers) + +## Migration Rules + +Convert `fetch()` call sites to `RestEndpoint`: + +```ts +// Before: raw fetch wrapper +async function getUsers(token: string): Promise { + const res = await fetch('https://api.example.com/users', { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +// After: RestEndpoint (auth moves to base class) +import { RestEndpoint } from '@data-client/rest'; +import { User } from './User'; + +export const getUsers = new RestEndpoint({ + path: '/users', + schema: [User], +}); +``` + +### Key conversions + +| fetch pattern | RestEndpoint equivalent | +|---------------|----------------------| +| `headers` option | `getHeaders()` on base class | +| `res.ok` / status checks | Automatic — throws `NetworkError` on non-2xx | +| `res.json()` | Automatic — `parseResponse()` defaults to JSON | +| Base URL string concatenation | `urlPrefix` on base class | +| Query string building (`URLSearchParams`, template literals) | `searchParams` type + `searchToString()` override | +| `method: 'POST'` | `method` option on RestEndpoint | +| `body: JSON.stringify(data)` | Pass object directly — RestEndpoint serializes automatically | +| `credentials: 'include'` | `getRequestInit()` override on base class | +| `AbortController` / `signal` | `signal` option on RestEndpoint | + +### Fetch wrappers with shared config + +```ts +// Before: custom fetch wrapper +function apiFetch(path: string, options?: RequestInit) { + return fetch(`${BASE_URL}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${getToken()}`, + ...options?.headers, + }, + }); +} +const res = await apiFetch('/users'); +const users = await res.json(); + +// After: base class captures shared config +class ApiEndpoint extends RestEndpoint { + urlPrefix = BASE_URL; + + getHeaders(headers: HeadersInit) { + return { + ...headers, + Authorization: `Bearer ${getToken()}`, + }; + } +} + +export const getUsers = new ApiEndpoint({ + path: '/users', + schema: [User], +}); +``` + +### POST / mutation patterns + +```ts +// Before +async function createUser(data: UserInput): Promise { + const res = await fetch('https://api.example.com/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +// After +export const createUser = new RestEndpoint({ + path: '/users', + method: 'POST', + schema: User, +}); +``` + +### Error handling + +```ts +// Before +if (!res.ok) { + const body = await res.json(); + throw new Error(body.message); +} + +// After — RestEndpoint throws NetworkError on non-2xx +import { NetworkError } from '@data-client/rest'; +try { ... } catch (err) { + if (err instanceof NetworkError) { + console.log(err.status); // HTTP status code + const body = await err.response.json(); + console.log(body.message); + } +} +``` + +## Reference + +- [RestEndpoint API](https://dataclient.io/rest/api/RestEndpoint) — lifecycle methods reference +- [NetworkError](https://dataclient.io/rest/api/RestEndpoint#fetchResponse) — error class +- [Authentication guide](https://dataclient.io/rest/guides/auth) — token and cookie patterns diff --git a/.agents/skills/data-client-setup/references/data-client-rest-setup/references/got-migration.md b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/got-migration.md new file mode 100644 index 000000000000..74bd28606f8d --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/got-migration.md @@ -0,0 +1,151 @@ + + +# Got → @data-client/rest Migration + +## Detection + +- `"got"` in `package.json` dependencies (typically Node.js server-side only) +- `import got from 'got'` in source + +Search patterns: +- `import.*from ['"]got['"]` — import statements +- `got\(` or `got\.(get|post|put|patch|delete)` — direct calls + +> **Note**: Got is a Node.js-only HTTP library. If migrating a server-rendered app (Next.js API routes, Express handlers), these endpoints may only run server-side. + +## Migration Rules + +### Basic requests + +```ts +// Before: got +import got from 'got'; +const users = await got('https://api.example.com/users', { + headers: { Authorization: `Bearer ${token}` }, + searchParams: { page: 1 }, +}).json(); + +// After: RestEndpoint +import { RestEndpoint } from '@data-client/rest'; +import { User } from './User'; + +export const getUsers = new RestEndpoint({ + path: '/users', + searchParams: {} as { page?: number }, + schema: [User], +}); +``` + +### Key conversions + +| Got pattern | RestEndpoint equivalent | +|-------------|----------------------| +| `prefixUrl` | `urlPrefix` | +| `headers` option | `getHeaders()` | +| `searchParams` | `searchParams` type (same concept, different runtime) | +| `hooks.beforeRequest` | `getHeaders()` or `getRequestInit()` | +| `hooks.afterResponse` | `process()` | +| `hooks.beforeError` | `fetchResponse()` override | +| `.json()` | Automatic — RestEndpoint parses JSON by default | +| `retry` | Not built in; use error-policy for retry behavior | +| `timeout` | `signal: AbortSignal.timeout(ms)` | +| `responseType: 'json'` | Default behavior | +| `responseType: 'buffer'` | `parseResponse()` override with `response.arrayBuffer()` | + +### Got instance → base class + +```ts +// Before: got instance with shared config +import got from 'got'; + +const api = got.extend({ + prefixUrl: 'https://api.example.com', + headers: { Authorization: `Bearer ${token}` }, + hooks: { + beforeRequest: [ + options => { + options.headers['X-Request-Id'] = generateId(); + }, + ], + afterResponse: [ + (response) => { + logResponse(response.statusCode); + return response; + }, + ], + }, +}); + +const users = await api('users').json(); + +// After: base class +class ApiEndpoint extends RestEndpoint { + urlPrefix = 'https://api.example.com'; + + getHeaders(headers: HeadersInit) { + return { + ...headers, + Authorization: `Bearer ${token}`, + 'X-Request-Id': generateId(), + }; + } + + async parseResponse(response: Response) { + const result = await super.parseResponse(response); + logResponse(response.status); + return result; + } +} +``` + +### Error handling + +```ts +// Before +import { HTTPError } from 'got'; +try { ... } catch (err) { + if (err instanceof HTTPError) { + console.log(err.response.statusCode); + console.log(err.response.body); + } +} + +// After +import { NetworkError } from '@data-client/rest'; +try { ... } catch (err) { + if (err instanceof NetworkError) { + console.log(err.status); + const body = await err.response.json(); + } +} +``` + +### Pagination + +```ts +// Before: got pagination +import got from 'got'; +const allUsers = await got.paginate.all('https://api.example.com/users', { + pagination: { + transform: (response) => JSON.parse(response.body), + paginate: ({ currentItems, response }) => { + const next = response.headers.link?.match(/<([^>]+)>; rel="next"/)?.[1]; + return next ? { url: new URL(next) } : false; + }, + }, +}); + +// After: use paginationField or getPage +const UserResource = resource({ + path: '/users', + schema: User, + paginationField: 'cursor', +}); +// ctrl.fetch(UserResource.getList.getPage, { cursor: nextCursor }) +``` + +## Reference + +- [RestEndpoint API](https://dataclient.io/rest/api/RestEndpoint) — lifecycle methods reference +- [NetworkError](https://dataclient.io/rest/api/RestEndpoint#fetchResponse) — error class +- [Pagination guide](https://dataclient.io/rest/guides/pagination) — cursor/offset pagination patterns diff --git a/.agents/skills/data-client-setup/references/data-client-rest-setup/references/hookifyResource.md b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/hookifyResource.md new file mode 100644 index 000000000000..9ad9ed432141 --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/hookifyResource.md @@ -0,0 +1,221 @@ + + +# hookifyResource + +`hookifyResource()` Turns any [Resource](./resource.md) (collection of [RestEndpoints](./RestEndpoint.md)) into a collection +of hooks that return [RestEndpoints](./RestEndpoint.md). + +> **Info** +> +> TypeScript >=4.3 is required for generative types to work correctly. + +```ts title="resources/Article" +import React from 'react'; +import { Collection, Entity, Invalidate, hookifyResource, resource } from '@data-client/rest'; + +class Article extends Entity { + id = ''; + title = ''; + content = ''; +} +const AuthContext = React.createContext(''); + +const ArticleResourceBase = resource({ + urlPrefix: 'http://test.com', + path: '/article/:id', + schema: Article, +}); +export const ArticleResource = hookifyResource( + ArticleResourceBase, + function useInit() { + const accessToken = React.useContext(AuthContext); + return { + headers: { + 'Access-Token': accessToken, + }, + }; + }, +); +``` + +```tsx title="ArticleDetail" +import { useSuspense, useController } from '@data-client/react'; +import { ArticleResource } from './resources/Article'; +import ArticleForm from './ArticleForm'; + +function ArticleDetail({ id }) { + const article = useSuspense(ArticleResource.useGet(), { id }); + const updateArticle = ArticleResource.useUpdate(); + const ctrl = useController(); + const onSubmit = (body: any) => ctrl.fetch(updateArticle, { id }, body); + + return ; +} +render(); +``` + +## Members + +Assuming you use the unchanged result of [resource()](./resource.md), these will be your methods + +### useGet() + +- method: 'GET' +- path: `path` +- schema: [schema](https://dataclient.io/rest/api/Entity) + +```typescript +// GET //test.com/api/abc/xyz +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useGet()({ + group: 'abc', + id: 'xyz', +}); +``` + +Commonly used with [useSuspense()](https://dataclient.io/docs/api/useSuspense), [Controller.invalidate](https://dataclient.io/docs/api/Controller#invalidate) + +### useGetList() + +- method: 'GET' +- path: `shortenPath(path)` + - Removes the last `:param` or `*wildcard` token: + ```ts + hookifyResource(resource({ path: '/:first/:second' })).useGetList() + .path === '/:first'; + hookifyResource(resource({ path: '/:first' })).useGetList().path === + '/'; + hookifyResource(resource({ path: '/:owner/*path' })).useGetList() + .path === '/:owner'; + ``` +- schema: [\[schema\]](https://dataclient.io/rest/api/Array) + +```typescript +// GET //test.com/api/abc?isExtra=xyz +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useGetList()({ + group: 'abc', + isExtra: 'xyz', +}); +``` + +Commonly used with [useSuspense()](https://dataclient.io/docs/api/useSuspense), [Controller.invalidate](https://dataclient.io/docs/api/Controller#invalidate) + +### useGetList().push {#push} + +[push](./RestEndpoint.md#push) creates a new entity and pushes it to the end of useGetList(). + +- method: 'POST' +- path: `shortenPath(path)` +- schema: `useGetList().schema.push` + +```typescript +// POST //test.com/api/abc +// BODY { "title": "winning" } +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useGetList().push({ group: 'abc' }, { title: 'winning' }); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### useGetList().unshift {#unshift} + +[unshift](./RestEndpoint.md#unshift) creates a new entity and pushes it to the beginning of useGetList(). + +- method: 'POST' +- path: `shortenPath(path)` +- schema: `useGetList().schema.unshift` + +```typescript +// POST //test.com/api/abc +// BODY { "title": "winning" } +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useGetList().unshift({ group: 'abc' }, { title: 'winning' }); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### useGetList().getPage {#getpage} + +[getPage](./RestEndpoint.md#getpage) retrieves another [page](https://dataclient.io/rest/guides/pagination#infinite-scrolling) appending to useGetList() ensuring there are no duplicates. + +- method: 'GET' +- args: `shortenPath(path) & { [paginationField]: string | number } & searchParams` +- schema: [new Collection(\[schema\]).addWith(paginatedMerge, paginatedFilter(removeCursor))](https://dataclient.io/rest/api/Collection) + +```typescript +// GET //test.com/api/abc?isExtra=xyz&page=2 +hookifyResource( + resource({ + urlPrefix: '//test.com', + path: '/api/:group/:id', + paginationField: 'page', + }), +).useGetList().getPage({ + group: 'abc', + isExtra: 'xyz', + page: '2', +}); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### useUpdate() + +- method: 'PUT' +- path: `path` +- schema: `schema` + +```typescript +// PUT //test.com/api/abc/xyz +// BODY { "title": "winning" } +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useUpdate()({ group: 'abc', id: 'xyz' }, { title: 'winning' }); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### usePartialUpdate() + +- method: 'PATCH' +- path: `path` +- schema: `schema` + +```typescript +// PATCH //test.com/api/abc/xyz +// BODY { "title": "winning" } +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).usePartialUpdate()({ group: 'abc', id: 'xyz' }, { title: 'winning' }); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### useDelete() + +- method: 'DELETE' +- path: `path` +- schema: [new Invalidate(schema)](https://dataclient.io/rest/api/Invalidate) +- process: + ```ts + (value, params) { + return value && Object.keys(value).length ? value : params; + }, + ``` + +```typescript +// DELETE //test.com/api/abc/xyz +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useDelete()({ + group: 'abc', + id: 'xyz', +}); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) diff --git a/.agents/skills/data-client-setup/references/data-client-rest-setup/references/hookifyResource.vue.md b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/hookifyResource.vue.md new file mode 100644 index 000000000000..d6a5294debe7 --- /dev/null +++ b/.agents/skills/data-client-setup/references/data-client-rest-setup/references/hookifyResource.vue.md @@ -0,0 +1,229 @@ + + +# hookifyResource + +`hookifyResource()` Turns any [Resource](./resource.vue.md) (collection of [RestEndpoints](./RestEndpoint.vue.md)) into a collection +of composables that return [RestEndpoints](./RestEndpoint.vue.md). + +> **Info** +> +> TypeScript >=4.3 is required for generative types to work correctly. + +```ts title="resources/Article" +import { inject } from 'vue'; +import { Collection, Entity, Invalidate, hookifyResource, resource } from '@data-client/rest'; + +class Article extends Entity { + id = ''; + title = ''; + content = ''; +} +export const AuthKey = Symbol('accessToken'); + +const ArticleResourceBase = resource({ + urlPrefix: 'http://test.com', + path: '/article/:id', + schema: Article, +}); +export const ArticleResource = hookifyResource( + ArticleResourceBase, + function useInit() { + const accessToken = inject(AuthKey, ''); + return { + headers: { + 'Access-Token': accessToken, + }, + }; + }, +); +``` + +```html title="ArticleDetail.vue" + + + +``` + +Each `use*()` composable calls your function once, when the component is set up, so call them +at the top level of `