Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/modern-ghosts-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@dfsync/client': minor
---

- add support for PATCH HTTP method
- introduce client.patch(...) API
- improve method typing (body as second argument)
- enhance DX for HTTP method usage
16 changes: 0 additions & 16 deletions examples/node-basic/basic-get.ts

This file was deleted.

52 changes: 52 additions & 0 deletions examples/node-basic/basic-methods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createClient } from '@dfsync/client';

type Post = {
id: number;
title: string;
body: string;
userId: number;
};

const client = createClient({
baseUrl: 'https://jsonplaceholder.typicode.com',
timeout: 5000,
retry: {
attempts: 2,
},
});

async function main(): Promise<void> {
const posts = await client.get<Post[]>('/posts');

console.log('Posts:', posts.slice(0, 2));

const createdPost = await client.post<Post>('/posts', {
title: 'Hello from dfsync',
body: 'Created with @dfsync/client',
userId: 1,
});

console.log('Created post:', createdPost);

const patchedPost = await client.patch<Post>('/posts/1', {
title: 'Updated with PATCH',
});

console.log('Patched post:', patchedPost);

const deletedPost = await client.delete<undefined>('/posts/1');

console.log('Delete response (demo API does not persist changes):', deletedPost);

const singlePost = await client.request<Post>({
method: 'GET',
path: '/posts/1',
});

console.log('Single post via request():', singlePost);
}

main().catch((error) => {
console.error('Example failed:', error);
process.exit(1);
});
2 changes: 1 addition & 1 deletion examples/node-basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"example:basic-get": "tsx basic-get.ts",
"example:basic-methods": "tsx basic-methods.ts",
"example:bearer-auth": "tsx bearer-auth.ts",
"example:hooks": "tsx hooks.ts",
"example:custom-auth": "tsx custom-auth.ts",
Expand Down
36 changes: 31 additions & 5 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@ A lightweight and reliable HTTP client for service-to-service communication in N
Designed for backend services, microservices and internal APIs where consistent and reliable HTTP communication between services is required.

[![npm version](https://img.shields.io/npm/v/@dfsync/client.svg)](https://www.npmjs.com/package/@dfsync/client)
[![npm downloads](https://img.shields.io/npm/dm/@dfsync/client.svg)](https://www.npmjs.com/package/@dfsync/client)
[![npm downloads](https://img.shields.io/npm/dw/@dfsync/client.svg)](https://www.npmjs.com/package/@dfsync/client)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)

Home page:
[https://dfsyncjs.github.io](https://dfsyncjs.github.io)
Home page: [https://dfsyncjs.github.io](https://dfsyncjs.github.io)

Full documentation:
[https://dfsyncjs.github.io/#/docs](https://dfsyncjs.github.io/#/docs)
Full documentation: [https://dfsyncjs.github.io/#/docs](https://dfsyncjs.github.io/#/docs)

## Install

Expand All @@ -31,8 +29,35 @@ const client = createClient({
});

const users = await client.get('/users');

const createdUser = await client.post('/users', {
name: 'John',
});

const updatedUser = await client.patch('/users/1', {
name: 'Jane',
});
```

## HTTP methods

`@dfsync/client` provides a small and predictable method surface:

```text
client.get(path, options?)
client.delete(path, options?)

client.post(path, body?, options?)
client.put(path, body?, options?)
client.patch(path, body?, options?)

client.request(config)
```

`get` and `delete` do not accept `body` in options.

`post`, `put`, and `patch` accept request body as a separate second argument.

## Main features

- typed responses
Expand All @@ -41,6 +66,7 @@ const users = await client.get('/users');
- consistent error handling
- auth support: bearer, API key, custom
- lifecycle hooks: `beforeRequest`, `afterResponse`, `onError`
- support for `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`
- retry policies

## How requests work
Expand Down
31 changes: 26 additions & 5 deletions packages/client/src/core/create-client.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import type { Client } from '../types/client';
import type { ClientConfig } from '../types/config';
import type { RequestConfig, RequestOptions } from '../types/request';
import type { RequestConfig, RequestOptionsWithoutBody } from '../types/request';
import { request } from './request';

export function createClient(config: ClientConfig): Client {
return {
get<T = unknown>(path: string, options?: RequestOptions): Promise<T> {
get<T = unknown>(path: string, options?: RequestOptionsWithoutBody): Promise<T> {
return request<T>(config, {
...options,
method: 'GET',
path,
});
},

post<T = unknown>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
post<T = unknown>(
path: string,
body?: unknown,
options?: RequestOptionsWithoutBody,
): Promise<T> {
return request<T>(config, {
...options,
method: 'POST',
Expand All @@ -22,7 +26,11 @@ export function createClient(config: ClientConfig): Client {
});
},

put<T = unknown>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
put<T = unknown>(
path: string,
body?: unknown,
options?: RequestOptionsWithoutBody,
): Promise<T> {
return request<T>(config, {
...options,
method: 'PUT',
Expand All @@ -31,7 +39,20 @@ export function createClient(config: ClientConfig): Client {
});
},

delete<T = unknown>(path: string, options?: RequestOptions): Promise<T> {
patch<T = unknown>(
path: string,
body?: unknown,
options?: RequestOptionsWithoutBody,
): Promise<T> {
return request<T>(config, {
...options,
method: 'PATCH',
path,
body,
});
},

delete<T = unknown>(path: string, options?: RequestOptionsWithoutBody): Promise<T> {
return request<T>(config, {
...options,
method: 'DELETE',
Expand Down
16 changes: 11 additions & 5 deletions packages/client/src/types/client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import type { RequestConfig, RequestOptions } from './request';
import type { RequestOptionsWithoutBody, RequestConfig } from './request';

export type Client = {
get<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
post<T = unknown>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
put<T = unknown>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
delete<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
get<T = unknown>(path: string, options?: RequestOptionsWithoutBody): Promise<T>;

delete<T = unknown>(path: string, options?: RequestOptionsWithoutBody): Promise<T>;

post<T = unknown>(path: string, body?: unknown, options?: RequestOptionsWithoutBody): Promise<T>;

put<T = unknown>(path: string, body?: unknown, options?: RequestOptionsWithoutBody): Promise<T>;

patch<T = unknown>(path: string, body?: unknown, options?: RequestOptionsWithoutBody): Promise<T>;

request<T = unknown>(config: RequestConfig): Promise<T>;
};
6 changes: 4 additions & 2 deletions packages/client/src/types/request.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { HeadersMap, QueryParams } from './common';
import type { RetryConfig } from './config';

export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

export type RequestConfig = {
method: RequestMethod;
Expand All @@ -14,4 +14,6 @@ export type RequestConfig = {
signal?: AbortSignal;
};

export type RequestOptions = Omit<RequestConfig, 'method' | 'path' | 'body'>;
export type RequestOptionsWithoutBody = Omit<RequestConfig, 'method' | 'path' | 'body'>;

export type RequestOptions = RequestOptionsWithoutBody;
56 changes: 56 additions & 0 deletions packages/client/tests/integration/client-patch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest';

import { createClient } from '../../src/core/create-client';
import { getFirstFetchInit, getFirstMockCall } from '../testUtils';

describe('client.patch', () => {
it('sends PATCH request with body', async () => {
const fetchMock = vi.fn(
async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => {
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: {
'content-type': 'application/json',
},
});
},
);

const client = createClient({
baseUrl: 'https://api.test.com',
fetch: fetchMock,
});

const body = { name: 'John' };

const result = await client.patch('/users/1', body);

expect(result).toEqual({ success: true });

expect(fetchMock).toHaveBeenCalledTimes(1);

const [input] = getFirstMockCall(fetchMock);
const init = getFirstFetchInit(fetchMock);

expect(input).toBe('https://api.test.com/users/1');
expect(init?.method).toBe('PATCH');
expect(init?.body).toBe(JSON.stringify(body));
});

it('works without body', async () => {
const fetchMock = vi.fn(async (): Promise<Response> => {
return new Response(null, { status: 204 });
});

const client = createClient({
baseUrl: 'https://api.test.com',
fetch: fetchMock,
});

await client.patch('/users/1');

const init = getFirstFetchInit(fetchMock);
expect(init?.method).toBe('PATCH');
expect(init?.body).toBeUndefined();
});
});
41 changes: 41 additions & 0 deletions packages/client/tests/types/client-methods.type-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { createClient } from '../../src';

const client = createClient({
baseUrl: 'https://api.test.com',
});

// allowed
client.get('/users');
client.delete('/users/1');

client.post('/users', { name: 'John' });
client.put('/users/1', { name: 'John' });
client.patch('/users/1', { name: 'Jane' });

client.get('/users', {
headers: {
authorization: 'Bearer token',
},
});

client.post('/users', { name: 'John' }, { timeout: 5000 });

// body must not be allowed for GET
// @ts-expect-error body is not allowed in GET options
client.get('/users', { body: { invalid: true } });

// body must not be allowed for DELETE
// @ts-expect-error body is not allowed in DELETE options
client.delete('/users/1', { body: { invalid: true } });

// body must not be allowed in options for POST
// @ts-expect-error body must be passed as the second argument, not in options
client.post('/users', { name: 'John' }, { body: { invalid: true } });

// body must not be allowed in options for PUT
// @ts-expect-error body must be passed as the second argument, not in options
client.put('/users/1', { name: 'John' }, { body: { invalid: true } });

// body must not be allowed in options for PATCH
// @ts-expect-error body must be passed as the second argument, not in options
client.patch('/users/1', { name: 'Jane' }, { body: { invalid: true } });